Skip to content

feat(iptc): typed IPTC Extension structures and the complete IIM 4.2 tag table - #540

Open
justin13888 wants to merge 28 commits into
masterfrom
feat/422-iptc-extension-iim-records
Open

justin13888 wants to merge 28 commits into
masterfrom
feat/422-iptc-extension-iim-records

Conversation

@justin13888

@justin13888 justin13888 commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes the breadth half of gamut-iptc's v1 deferral list — the modelling gap issue #422 names
against exiv2 — without touching the reconciliation keystone.

Typed models for the structured IPTC properties (crates/gamut-iptc/src/extension.rs, new).
Iptc4xmpCore:CreatorContactInfo, Iptc4xmpExt:ImageRegion (with RegionBoundary,
RegionBoundaryPoint and Entity), Iptc4xmpExt:ArtworkOrObject and plus:Licensor become typed
projections over the XMP graph, each with from_xmp/to_xmp and an accessor pair on
PhotoMetadata — the shape gamut_exif::GpsInfo::from_ifd/to_ifd already uses.

A projection is narrower than the graph, so every one of the seven types keeps what it cannot
express. Retention is decided by round-trippability, not by readability: a field becomes part of
the typed value only when the property the writer will emit for that value reproduces the field
that was read — same value, same RDF container kind, same qualifiers. Everything else stays in that
type's other list verbatim and is re-emitted after the modelled fields.

That one rule covers three classes at once: a field the model does not name (a vendor extension, or
the "any other metadata property" an ImageRegion may carry); a field it names but cannot read (a
coordinate whose text is not a number, an identifier holding a structure); and — the class an
earlier read-side rule silently destroyed — a field it can read but could not write back as it
stands. Thirteen shapes fall in that third class, and the module derives the list from its own shape
corpus rather than restating it:

Shape What used to happen
text: a URL held as rdf:resource re-emitted as element text — <x rdf:resource="…"/> became <x>…</x>
text: a value carrying a qualifier the qualifier (e.g. xml:lang="fr" on CiAdrCity) was dropped
lang alt: another language beside the default the other languages were dropped
lang alt: plain text where an alternative belongs rewritten as an rdf:Alt
list: an rdf:Alt where an array belongs flattened to a Bag, destroying every language tag
list: an rdf:Seq where an rdf:Bag belongs the container kind was rewritten
list: an item that is not text the item was dropped
list: an item held as rdf:resource re-emitted as element text
number: text with no XMP Real value NaN, an infinity and 1e400 were destroyed — read, marked consumed, then refused on write
nested: a structure carrying a qualifier the qualifier was dropped
nested array: a bare structure where an array belongs normalised into a one-element array
nested array: an rdf:Bag where an rdf:Seq belongs the container kind was rewritten
nested array: an item that is not a structure the item was dropped

Reading a structure and writing it back now changes nothing but three idempotent re-spellings:
a structure's fields come back in the model's order (they are an unordered set, XMP Part 1 §6.3.3),
a number may be re-spelled (0.500.5), and an X-Default tag is re-cased (Part 1 §8.2.2.4
matches it case-insensitively). All three are documented on the module.

The same rule applies to the property, not only to the fields inside it. The four accessor pairs
on PhotoMetadata report a value only when writing it back gives the property back; anything else
reads as absent and is left exactly where it lies, because at the top level the graph is the
retention list a structure keeps in its other field. That closes seven shapes a read-then-set
through those pairs used to destroy — an array member that is not a structure, a qualifier on the
property, a qualifier on an rdf:li, an rdf:Alt rewritten to rdf:Bag, a bare structure
rewritten to a Bag, an array or structure holding nothing at all, and a qualifier on the
contact-info property. An array setter now keeps the container kind the property already carries
(Alt and Bag as well as Seq), and two top-level properties of one name are neither read nor
lost, as two fields of one name already were inside a structure. It is pinned by a generated cross
of every accessor pair × every value shape × every qualifier list — 240 cases — rather than by a
hand list of the shapes someone thought of.

The complete IIM 4.2 record-1/record-2 tag table (crates/gamut-iptc/src/iim.rs). KNOWN_TAGS
grows from 24 to 71 entries: every dataset IPTC-IIM 4.2 chapters 5 and 6 give a determinate
octet maximum — 14 Envelope + 56 Application rows — plus the one dataset from chapter 11,
7:10 Size Mode, which the spec fixes at one octet. Transcribed from
references/iptc/iim-4.2.pdf, and pinned back to it: the standard sets every DataSet's name in a
column of its own, which pdftotext -bbox-layout recovers by position, so the names are compared
against the standard itself rather than against a hand-kept table. The table is descriptive only:
no FIELD_MAP row references a newly named dataset, so reading, merging and writing are
byte-for-byte unchanged, and nothing outside gamut-iptc reads IimTagInfo.

No breaking change: everything here is additive.

Validation

Run from the lane's worktree. mise run fmt/fmt-check carry the __CARGO_TEST_ROOT prefix that
the nested-worktree cargo-root artefact requires; workspace-wide gates ran inside a
MemoryMax=16G systemd scope with CARGO_BUILD_JOBS=2.

Command Result
cargo test -p gamut-iptc --all-features pass — 100 lib + 5 oracle + 6 techreference + 4 doc, 0 failed
cargo clippy -p gamut-iptc --all-targets --all-features pass — clean
__CARGO_TEST_ROOT=… mise run fmt-check pass
mise run check-tests pass — module docs, pinned seeds, oracle filenames conform
mise run check-commits pass — convco check reports no errors in 24 commits
mise run lint pass — whole workspace, exit 0
mise run test pass — whole workspace, 0 failures
mise run mutants-diff pass — base origin/master, 152 mutants, 144 caught, 8 unviable, 0 missed

mise run check-release-deps / check-ffi-features / check-ffi-header were not run: no
Cargo.toml and no public C-surface type changed. mise run coverage was not run here; the CI
coverage gate ran on this branch and passed.

Test technique, per docs/testing.md:

  • drift guard against the standard, inline (src/iim.rs): every dataset name in the table is
    compared against the name the standard itself sets, in the DataSet column
    pdftotext -bbox-layout recovers by position from references/iptc/iim-4.2.pdf.
    tests/data/extract-iim-names.py performs the extraction and writes
    tests/data/iim-4.2-dataset-names.tsv; the artefact is committed with the command that
    regenerates it recorded beside it, because pdftotext is a system package the toolchain does not
    provision. Renaming 2:92 to "Sublocation" now fails the gate; the six datasets the standard
    names and gamut deliberately does not are pinned as absent, in the same place.
  • drift guard against exiv2, inline (src/iim.rs): the columns the standard states in prose
    rather than in a column — octet maximum, repeatability and value kind — are compared against
    exiv2's independent transcription of the same chapters, parsed at test time from
    third_party/exiv2/{include/exiv2/datasets.hpp,src/datasets.cpp}. Both guards are inline, not in
    tests/, because they read the non-pub KNOWN_TAGS and .cargo/mutants.toml sets
    test_workspace = false.
  • drift guard (tests/techreference.rs): each modelled structure's field set is re-derived from
    the vendored ipmd_struct at test time and compared with what to_xmp emits, so an invented,
    missed or mis-prefixed field fails rather than drifts. The four top-level properties are pinned to
    the ipmd_top XMPid and structure type, and their absence of an IIMid — what makes a
    structured property unable to conflict — is pinned there too, not merely documented. This is the
    issue's acceptance criterion.
  • differential (tests/oracle.rs): a new exiv2 cross-check over a stream spanning the Envelope
    record and the Application datasets outside the XMP-mapped subset. The existing fixture is
    record 2 only, so this is the first record-1 leg.
  • law over a shape corpus, inline (src/extension.rs): one corpus states every shape a modelled
    field can arrive in — the canonical form of each field kind and every departure from it a graph
    can carry. every_shape_survives_a_read_modify_write_unchanged drives the module's stated law
    over all of them, and retention_covers_every_shape_the_typed_read_parses_but_cannot_write_back
    derives the thirteen-row table above from the same corpus rather than listing it, so the docs
    cannot drift from the code.
  • inline unit (src/extension.rs): the reproduction relation is stated clause by clause, one
    test per clause — the numeric re-spelling, the URI target, a structure's fields as an unordered
    set, a qualifier list matched pairwise, and the language tag as the one qualifier matched without
    regard to case. Each fails for its own reason.

Each retention fix was checked against its falsifier before the fix was committed — the mutation
that reintroduces the defect was applied to the fixed tree and the suite re-run:

Reintroduced defect Tests that fail
decide consumption by whether the read yielded a value (drop the reproduction guard) 5 fail: every_shape_survives_a_read_modify_write_unchanged, retention_covers_every_shape_the_typed_read_parses_but_cannot_write_back, a_non_finite_coordinate_is_neither_written_nor_destroyed, a_bare_structure_reads_as_a_one_element_sequence, a_language_alternative_keeps_the_languages_beside_the_default
consume a field even when the read yields nothing a_field_the_model_cannot_read_is_kept_verbatim (only)
read a language alternative by position that test + a_default_entry_tagged_in_another_case_is_still_the_default
never write a retained field those + a_retained_field_is_written_when_the_modelled_field_is_absent, every_structure_keeps_the_field_it_does_not_model, image_region_round_trips_and_keeps_unmodeled_properties
stop retaining in Entity alone every_structure_keeps_the_field_it_does_not_model (only)
drop an array member that carries no field reading_an_array_and_setting_it_back_keeps_every_member (only)
rewrite an array's container kind on write setting_an_array_keeps_the_container_kind_the_property_already_has (only)
mistype a dataset name (2:25, 2:92, 7:10 each tried) tag_table_names_match_the_standards_own_dataset_names

Closing round (the top-level reproduction rule)

Commands run for the four commits 660c3bb3, ff2b36ea, 7d8eb755, cd0d6969, in a nested
worktree. Every row below was re-run on the final commit; the counts are that run's.
fmt-check carries the __CARGO_TEST_ROOT prefix the nested-worktree cargo-root artefact requires;
the workspace-wide gates ran inside a MemoryMax=16G systemd scope with CARGO_BUILD_JOBS=2.

Command Result
cargo test -p gamut-iptc --all-features pass — 102 lib + 5 oracle + 6 techreference + 4 doc, 0 failed
cargo clippy -p gamut-iptc --all-targets --all-features -- -D warnings pass — clean
__CARGO_TEST_ROOT=… mise run fmt-check pass
mise run check-tests pass
mise run check-commits pass — convco check reports no errors in 28 commits
mise run lint pass — whole workspace, exit 0
mise run test pass — whole workspace, 202 test result: ok, 0 failures
mise run mutants-diff pass — 167 mutants, 157 caught, 10 unviable, 0 missed

CI on 7d8eb755 (the first three commits): Format & Metadata, Clippy & Doctests, Coverage (test
gate) and all four Incremental (PR diff) mutation shards pass. The fourth commit is module
documentation only. CI then ran again on the head cd0d6969: Format & Metadata,
Clippy & Doctests, Coverage (test gate) and all four Incremental (PR diff) shards pass there
too.

The mutation base, and how it was established. mise run mutants-diff selects against
origin/master. git merge-base origin/master HEAD is 6a75ec4a, which is git rev-parse origin/master exactly — so this branch is a direct descendant of master with 27 commits of its
own and nothing of another pull request folded in. No GAMUT_MUTANTS_BASE override applies.

check-release-deps / check-ffi-features / check-ffi-header were not run: no Cargo.toml and
no public C-surface type changed. coverage was not run: no new module, and the change is dense
with tests.

What the mutation gate cannot see. KNOWN_TAGS generates no mutants at all — it is data,
and a wrong constant in a table is not a mutation anyone generates. Its whole defence is the two
drift guards, and one of them compares the table against a committed artefact rather than against
the PDF (issue #623, and the corrected wording in STATUS.md). Separately, the ordering clause of
the reproduction relation has no mutant either: see the unresolved notes.

Each new clause was checked against its own falsifier, by reverting the clause on the fixed tree
and re-running the suite:

Reverted clause Tests that fail
the top-level reproduction gate (report whatever parsed) every_top_level_shape_survives_a_read_modify_write_unchanged, a_bare_structure_where_an_array_belongs_reads_as_absent_at_both_levels, an_array_member_the_model_cannot_write_back_makes_the_property_read_as_absent
keep the rdf:Alt container kind setting_an_array_keeps_the_container_kind_the_property_already_has (only)
remove the property whenever there is nothing to write every_top_level_shape_survives_a_read_modify_write_unchanged, two_top_level_properties_of_one_name_are_neither_read_nor_lost
read the first of two properties of one name two_top_level_properties_of_one_name_are_neither_read_nor_lost (only)

Risks and rollout

  • Behavioural risk: low. The tag table is inert (nothing consults it for an unmapped dataset)
    and the extension module is new surface.
  • The one behaviour change reaches beyond this crate. IPTC_NAMESPACES gains PLUS, so
    PhotoMetadata::from_xmp now retains plus: properties it previously dropped. Stated plainly:
    an XMP graph carrying only licensing-namespace properties now reports IPTC metadata where it
    previously reported none.
    That is required for the Licensor model to be reachable at all —
    the IPTC Extension defines plus:Licensor in the PLUS namespace — and it is additive, but a
    caller that treats "no IPTC metadata" as a branch will now take the other branch for such a file.
    The callers checked, by grep for gamut_iptc across crates/ and tooling/: the only
    consumer outside this crate is gamut-metadata, and it branches on this in two places.
    Metadata::iptc() (crates/gamut-metadata/src/metadata.rs) returns Some when the XMP carries
    any IPTC-namespace property, so a PLUS-only graph now yields Some(PhotoMetadata) where it
    previously yielded None — that is the change, stated. MetadataEmbedder::encode_iim
    (crates/gamut-metadata/src/embed.rs) calls it and then checks block.datasets.is_empty(), and
    plus: properties have no IIM counterpart, so the emitted IIM block is None either way: its
    observable behaviour is unchanged. crates/gamut/src/lib.rs re-exports only; the oracle crate
    under tooling/ builds exiv2 and does not call this path.
  • Fidelity is documented on the module, and the reproduction rule narrows what "loss" can mean.
    A read-modify-write preserves every field the model cannot reproduce; the three differences that
    remain are the idempotent re-spellings listed in the summary. The projection still reports
    such a field as absent rather than widening the model to carry it — that is the deferral filed as
    gamut-iptc: an extension field the projection cannot express reads as absent — should the model widen? #609, not a loss.
  • Deciding retention on the write side costs one throwaway value per field read — the very
    value the writer would emit — plus, for a structure field, a throwaway conversion of the
    structure below it. IPTC structures nest three deep at most and hold a handful of fields each,
    so the cost is bounded by the size of the structure being read; nothing here is on an encoder's
    pixel path.
  • The top-level gate changes what four accessors report, and it is the change to read first.
    creator_contact_info, image_regions, artwork_or_objects and licensors now return nothing
    for a property they would have destroyed on the way back out. Concretely: a bare structure written
    where the standard puts an array of structures used to read as that array's single element and now
    reads as absent; an array holding one member that is not a structure used to report the members
    that were, and now reports none. All of this surface is new in this pull request — none of it is on
    master — so no released behaviour changes, and nothing outside gamut-iptc calls these four pairs
    (checked by grep across crates/ and tooling/; the only hits are this crate's own tests). The
    data is not lost in any of these cases: the property is left untouched and reachable through
    PhotoMetadata::xmp. Whether the model should widen so these read as a value rather than as
    absent is the same open question as gamut-iptc: an extension field the projection cannot express reads as absent — should the model widen? #609.
  • Rollout: additive within gamut-iptc; release-plz will take a minor bump. No Cargo.toml
    changed, so check-release-deps / check-ffi-features are unaffected.
  • Rollback: revert the commits on this branch; nothing else depends on them.

Issue

Refs #422. Not Closes, because six pieces are filed rather than delivered:

Decisions taken

This is an unattended run. No human approved this plan; the decision record below is what a
human reads afterwards. The user's own words declaring the run unattended, verbatim:

Resolve all PRs and iteratively and properly and exhaustively resolve all issues that do not
require extensive R&D and experimentations. List the issues and PRs are you covering (do need
to mention what you're skipping)

Read the record with this correction in hand. Decision 19 below (the rdf:Alt leniency)
is superseded; its recorded evidence was wrong. The entry is left frozen as written, and the
correction is decision 26, appended at the end of this section. Decisions 13 (retention by
consumption) and 12 (a non-finite coordinate skipped on emit) are likewise superseded, by
decisions 22 and 23.

The frozen decision record this lane was launched with, verbatim:

Issue 422  Plan: v1  Branch: feat/422-iptc-extension-iim-records  Base: origin/master
Touches: crates/gamut-iptc/src/iim.rs (records 3-9 tables), schema.rs, new extension.rs,
         lib.rs, tests/techreference.rs, tests/, STATUS.md
Will not: change ConflictPolicy defaults; add an external dependency; touch other crates
Settled: S3 (tests follow docs/testing.md)

Decisions taken.
1. Boundary: IIM records 3-9 tables derived from the vendored JSON, plus the four IPTC
   Extension structures the issue prioritises (ImageRegion, ArtworkOrObject, Licensor,
   CreatorContactInfo) modelled as typed structs over the XMP graph, with from/to
   conversions in the shape gamut-exif's GpsInfo already uses.
   Rejected: every Extension structure - unbounded, and the issue prioritises these.
   Filed: the remainder as a new issue before the PR opens.
   Reverses: extend extension.rs.
2. Conflict behaviour for structured fields: a field with no IIM counterpart is XMP-only and
   therefore never a conflict; document that on the type rather than inventing a policy.
   Reverses: add an IIM mapping for it.

Appended during delivery:

3. Taken: the IIM tag table is completed for records 1 and 2 - every dataset iim-4.2.pdf gives a
   determinate octet maximum - rather than only records 3-9.
   Evidence: exiv2's 71 documented datasets are 14 Envelope + 57 Application rows
   (third_party/exiv2/src/datasets.cpp, envelopeRecord/application2Record); NONE of them is in
   records 3-9, so a records-3-9-only table cannot close the count gap the issue names. And
   iim-4.2.pdf defines no datasets at all in record 3 ("see separate publication"), records 4-5
   ("Not Allocated") or record 6 (Appendix F lists method identifiers, not datasets). Records 7-9
   hold six datasets, all excluded by decision 4. Records 3-9 alone would therefore have delivered
   zero entries.
   Rejected: records 3-9 only - delivers nothing; and the addition is inert (no FIELD_MAP row
   references a newly named dataset), so it carries no behavioural risk.
   Reverses: delete the record-1/2 rows from KNOWN_TAGS.

4. Taken: 2:202 and every dataset of records 7-9 (7:10, 7:20, 7:90, 7:95, 8:10, 9:10) are left
   unnamed and filed as #539.
   Evidence: IimTagInfo::max_octets is a u16 stating the dataset's maximum value-field length.
   iim-4.2.pdf gives 2:202 a 256000-octet maximum (beyond u16::MAX) and states none at all for the
   records 7-9 datasets ("a binary number", width unstated; 8:10 is the objectdata itself).
   Naming them needs max_octets widened - a BREAKING change to a published v1 crate - and an edit
   to reconcile.rs::push_dataset, which this lane's manifest excludes.
   Rejected: a 0 or u16::MAX sentinel - states a maximum the spec does not, and push_dataset
   enforces max_octets as a hard strict-write limit, so the sentinel would become a wrong rejection
   the moment such a dataset were mapped.
   Reverses: widen max_octets, update push_dataset, add the seven rows (that is #539).

5. Taken: the PLUS namespace URI is declared in gamut_iptc::schema::ns and added to
   IPTC_NAMESPACES.
   Evidence: plus:Licensor is an IPTC Extension property (ipmd_top "licensors",
   ipmdschema "IptcExt") in the PLUS namespace, and PhotoMetadata::from_xmp filters by
   IPTC_NAMESPACES - a typed Licensor that from_xmp discards would be useless. gamut_xmp's
   WellKnownNs carries no PLUS entry, and the manifest forbids touching another crate, so the URI
   is declared locally and pinned against the standard's literal string in schema's own tests.
   Rejected: adding PLUS to gamut_xmp::WellKnownNs - outside this lane's manifest.
   Rejected: adding xmp: to IPTC_NAMESPACES - xmp: is general-purpose; xmp:Identifier appears only
   INSIDE a structure, which top-level filtering never sees.
   Reverses: remove ns::PLUS from IPTC_NAMESPACES.

6. Taken: the extension structures are #[non_exhaustive] with a derived Default.
   Evidence: IPTC adds fields to these structures in most annual editions (the reference records a
   "History Note" version per field), and adding one to a struct with public fields is otherwise a
   breaking change. The cost is that a downstream caller builds from Default and assigns rather
   than using a struct literal; that is what tests/techreference.rs demonstrates.
   Reverses: drop the attribute.

7. Taken: crates/gamut-iptc/README.md was edited although the manifest did not list it.
   Evidence: the manifest listed lib.rs and STATUS.md, and the README carries the same two claims
   verbatim ("all IPTC Extension structures ... have no typed model", "records 3-9 are
   preserved"). Leaving it would have published a statement this PR makes false. The edit is
   confined to those two bullets, in the same crate, and changes no code.
   Rejected: freezing the lane for a manifest revision - disproportionate to a two-bullet doc
   correction inside the crate already being changed.
   Reverses: revert the README hunk in f34c62d9.

The second review round's decisions, delivered in commits 920a9f5f, f9b03b9d, 64f0c4d0,
5306b1d9 and 31306000. They are restated from the code that landed, not quoted:
the round-2 record was never written into this body, and this lane does not hold its text (see
decision 21):

8. Taken: 7:10 Size Mode is named, although it sits outside chapters 5 and 6.
   Evidence: IIM 4.2 Ch. 11 fixes it at "one octet", which is exactly the determinate maximum
   decision 4 makes the criterion - so excluding it would have made the criterion "chapters 5 and
   6" rather than "a maximum max_octets can state". The five datasets that genuinely have no
   stated width (7:20, 7:90, 7:95, 8:10, 9:10) stay unnamed under decision 4.
   Reverses: delete the 7:10 row and restate the criterion as a chapter range.

9. Taken: the record-1/2 tag table is pinned against exiv2's own dataset table, parsed from the
   vendored C++ sources at test time.
   Evidence: iim-4.2.pdf is not machine-readable, so 70 hand-transcribed rows had nothing but
   ordering and uniqueness to catch a slipped digit. exiv2 - already this crate's differential
   oracle - carries an independent transcription of the same chapters.
   Rejected: leaving the table unpinned and documenting that it is a transcription.
   Reverses: delete the guard and its C++ parse helpers.

10. Taken: the four top-level structures keep every field they do not model, verbatim, in a public
    `other` list, rather than only ImageRegion doing so.
    Evidence: the standard's "any other metadata property" allowance is why ImageRegion had one,
    but a vendor extension in a real-world file is dropped just as silently by the other three.
    Reverses: remove `other` from all but ImageRegion.

11. Taken: reading tolerates a bare structure written where the standard puts an array of
    structures, reading it as that array's single element.
    Evidence: it is a shape seen in the wild; writing still emits the standard array, so a
    read-modify-write normalises rather than propagates.
    Reverses: read only arrays and drop the bare structure.

12. Taken: a non-finite coordinate (NaN, +/-inf) is skipped on emit rather than written as text.
    Evidence: it is not a value of the XMP `Real` type, so writing one puts a value in the graph
    that no reader can take back as a number.
    Reverses: write `value.to_string()` unconditionally.

The third review round's decision record, verbatim as this lane received it:

1. Finding 1: retain the unparseable value verbatim rather than dropping it. A claim that nothing
   else is dropped must be true or absent, and making it true is the principle this run has
   applied throughout.
2. Finding 2: add the retention field to the three nested types. Losing a vendor field inside a
   boundary is the same defect as losing one beside it.
3. Finding 3: drop a namesake only when the modelled field is actually emitted.
4. Finding 4: use the XMP crate's existing default-matching. It is correct, and the no-duplication
   rule points straight at it. Round-1 code, but it destroys data and this pull request is open.
5. Finding 5: correct the body. Fix the arithmetic, the chapter attribution and the guard's
   location; append the five round-2 decisions in the record's shape; fill the unresolved notes;
   and state the cross-crate change in its decided framing - that a graph carrying only
   licensing-namespace properties now reports metadata where it previously reported none.
6. Teach the guard to compare names, since a name mutation currently survives everything.
7. Make the array setters skip empty elements, matching the setter that already does.
8. Keep the retained-fields collection public for now - fixing finding 3 removes the hazard that
   makes it reachable - and file the accessor question.

Appended by this lane while delivering that record:

13. Taken: retention is decided by CONSUMPTION - a field the typed read took no value from is
    retained - rather than by each type's list of modelled names.
    Evidence: decisions 1 and 2 are the same defect seen twice (a field outside the model, and a
    field inside it whose value the model cannot read). A `Reader` that marks a field used only
    when the read yields a value closes both with one rule that cannot drift from the model, and
    it deletes the four MODELLED name lists rather than growing them.
    Rejected: keeping MODELLED and adding a per-field "did it parse" flag - the flag would have to
    be written once per field per type, which is the drift the lists already invited.
    Reverses: restore the MODELLED lists and filter by name.

14. Taken: the `x-default` entry is matched through `XmpItem::lang()` with `eq_ignore_ascii_case`,
    the same predicate `XmpMeta::get_lang_alt` applies to a top-level property.
    Evidence: `get_lang_alt` takes an `XmpMeta`, and a structure's fields are a `&[XmpProperty]`;
    calling it would mean deep-cloning every structure's field list into an `XmpMeta` on each
    read. What is actually shared - the `xml:lang` qualifier lookup and the case-insensitive
    comparison - is reused; what is duplicated is the two-line predicate.
    Rejected: cloning into an `XmpMeta` per structure read - allocation-conscious is a crate rule.
    Rejected: adding a slice-level helper to gamut-xmp - outside this lane's scope.
    Reverses: replace the predicate with a call into gamut-xmp once it offers a slice-level
    accessor.

15. Taken: a language alternative with no `x-default` entry reads as absent and is retained
    verbatim, rather than falling back to another language.
    Evidence: the fallback is what destroyed the French entry in the first place - it read one
    language and re-emitted it labelled as the default. Retaining costs nothing under decision 13
    and preserves every language the field carries.
    Reverses: fall back to the first entry when no default is tagged.

16. Taken: every name in KNOWN_TAGS is pinned, 42 by equality with exiv2's title and 28 by an
    explicit pair that pins both spellings.
    Evidence: exiv2 abbreviates 28 of the 70 shared titles ("Caption" for "Caption/Abstract",
    "Digitization Date" for "Digital Creation Date"); each abbreviation was checked against
    iim-4.2.pdf's own dataset heading before being pinned. A pair self-invalidates if either side
    changes, which is the shape LIMIT_EXCEPTIONS already uses in tests/techreference.rs.
    Rejected: normalising case and punctuation before comparing - removes only 4 of the 28 and
    weakens the check for the other 42, where a hyphen slip would stop failing.
    Rejected: comparing against the PMD JSON's IIMname - it covers only the 20 mapped datasets.
    Reverses: delete NAME_ABBREVIATIONS and the name assertions.

17. Taken: RegionBoundaryPoint loses its `Copy` derive, because it now owns a Vec of retained
    fields.
    Evidence: the type is introduced by this pull request and is not on master, so no released API
    changes. Losing `Copy` on a two-coordinate struct costs callers a `.clone()` they do not
    currently make anywhere in the workspace.
    Reverses: drop the retention field from that one type (which reopens finding 2 for it).

18. Taken: the array setters skip a member that carries no field at all, and remove the property
    when nothing is left.
    Evidence: decision 7. `set_creator_contact_info` already removed an empty block; a bag setter
    wrote an empty `rdf:li` that reads back as a present-but-blank member.
    Reverses: write every member the caller passes.

19. Taken: the `list` helper still accepts an `rdf:Alt` where a Bag or Seq is expected, although
    the sibling `photo_metadata::list` rejects one.
    Evidence: it is a leniency, not a loss - the items are read rather than dropped - and the
    round-3 record does not decide it. Changing it would alter read behaviour beyond the record.
    Recorded in the unresolved notes instead.
    Reverses: match only Bag|Seq, as photo_metadata::list does.

20. Taken: `photo_metadata::lang_alt` is left reading the first alternative rather than the
    x-default one.
    Evidence: it is master code, not this pull request's, and unlike the extension helper it does
    not destroy anything - `XmpMeta::set_lang_alt` updates one entry in place and leaves the other
    languages alone. Recorded in the unresolved notes.
    Reverses: give it the same tag match as the extension helper.

21. Taken: the round-2 decisions are restated from the code that landed, and labelled as restated,
    rather than left absent.
    Evidence: the round-3 record requires them appended; the round-2 record's text was never
    written into this body and this lane was not given it. An absent record is worse for a human
    reader than a faithful restatement that says what it is.
    Rejected: quoting them as verbatim - they are not.
    Reverses: replace the block with the original text if it is recovered.

The fourth review round's decision record, verbatim as this lane received it:

1. Design question 1, and it is the round: decide retention by round-trippability, not by
   consumption. A field is consumed only when what the writer will emit reproduces what the reader
   saw - value kind, qualifiers and all; otherwise it is retained verbatim. F-1, F-2 and F-3 all
   disappear under it, which is the test of whether it is the right axis. Three rounds have now
   closed this class on a narrower read-side path and had it reopen one level up; closing it on the
   write side is what ends it. Derive the mechanism yourself, and prove it two ways: every one of
   the reviewer's F-1/F-2/F-3 falsifiers must now retain rather than destroy, and sweeps A-D
   must still hold unchanged. Reverses: restore the consumption-only rule.
2. F-1: fix the test that pins the defect in. `a_non_finite_coordinate_is_not_written` must also
   drive the graph side - a coordinate stated as `NaN`, `inf` and `1e400` in the XMP - and the
   module docs' "through the typed API" scoping is corrected to say what the code does.
   Reverses: revert the test and the doc hunk.
3. F-2 and design question 2: preserve the value kind the reader saw. A URL field that arrived as
   `rdf:resource` leaves as `rdf:resource`. Where the projection cannot preserve a kind, decision 1
   retains the field instead - that is the fallback, not the primary answer. Reverses: collapse to
   `Simple` again.
4. F-3 and design question 3: do not widen the model - retain. A modelled field carrying
   qualifiers the projection cannot reproduce is kept verbatim under decision 1. Widening the model
   to carry qualifiers is a larger change than this issue's scope and would still leave the general
   case open. Reverses: make consumption value-only again.
5. F-4 and design question 6: reverse the leniency, and correct the record by appending. An
   `rdf:Alt` handed to a `list` helper is retained rather than flattened, because flattening
   destroys the language tags. Decision 19's entry is frozen: append a correction entry that names
   decision 19 and states what it got wrong, and put a pointer at the head of the decision-record
   section. Do not edit the frozen entry. Reverses: restore the leniency and the original wording.
6. F-5 and design question 4: pin the names to the standard mechanically, and correct the false
   premise in all four places. The reviewer executed the extraction and got 71/71 with zero
   mismatches, so the stated reason for the design does not hold. Preferred: extract from
   `iim-4.2.pdf` and delete `NAME_ABBREVIATIONS`. If `pdftotext` is not provisioned for the gate -
   check `mise.toml` before deciding - commit the extracted names as a derived artefact with the
   extraction command recorded beside it, so a re-derivation is possible and a consistent
   two-column typo fails. Whichever you take, the four statements claiming the PDF is not
   machine-readable are corrected. Reverses: restore the hand table and the premise.
7. F-6 and design question 5: keep empty members, and make the two writers agree. Idempotence is
   the contract the module states; `put_nested_array` already has it right. Reverses: restore the
   filter.
8. F-7: eliminate what decision 1 makes a loss, document what remains. Forcing an `rdf:Seq` to
   `rdf:Bag` is a container-kind loss and falls under decision 3; the `"1.0"` -> `"1"` lexical
   normalisation is idempotent and stays, listed in the module's fidelity list. F-8: add #573.
9. Anything decision 1 reveals that these findings do not name is in scope - that is the point of
   changing the axis. Enumerate what the new rule now retains that the old one destroyed, and
   publish the enumeration. Derive it; do not list it by hand.

Appended by this lane while delivering that record:

22. Taken: retention is decided by REPRODUCTION - a field is consumed only when the property the
    writer will emit for the value read reproduces the field that was read (value, RDF container
    kind and qualifiers) - and the read-side special cases are removed as it subsumes them.
    Supersedes decision 13, which decided it by consumption: whether the read yielded a value.
    Evidence: three shapes parsed cleanly and were then written back as something else, so the
    field was neither modelled nor retained but destroyed - an `rdf:resource` URL re-emitted as
    element text, a value's qualifiers dropped, and a coordinate stated as `NaN`, an infinity or
    `1e400` vanishing entirely. Applying the reviewer's F-1/F-2/F-3 falsifiers to the fixed tree
    now retains each of them; the shape corpus (13 shapes) still round-trips unchanged.
    Mechanism: every `parse_*` sits beside the `*_value` that writes its result, and the reader
    compares the field against that very value, so the two cannot drift apart.
    Rejected: another read-side special case per shape - three rounds closed this class that way
    and it reopened one level up each time.
    Reverses: restore the consumption-only rule in `Reader::read`.

23. Taken: exactly two lexical re-spellings still count as reproducing a field - a number's form
    (`0.50` -> `0.5`) and the case of an `x-default` language tag.
    Supersedes the "is it a loss?" half of decision 12: a non-finite coordinate is now retained
    verbatim rather than silently dropped, and the skip-on-emit it decided stands.
    Evidence: both are idempotent and value-preserving - doing them twice changes nothing more -
    and Part 1 §8.2.2.4 matches language tags case-insensitively. Anything else the writer would
    change is a difference, so the field is retained instead.
    Rejected: allowing whitespace or container-kind normalisation as "the same value" - neither is
    a re-spelling of a value, and the `rdf:Seq` -> `rdf:Bag` case destroys what a Seq states.
    Reverses: widen or narrow `same_value`.

24. Taken: the four statements claiming `iim-4.2.pdf` is not machine-readable are false and are
    corrected; the dataset names are pinned to the standard's own DataSet column, extracted by
    position with `pdftotext -bbox-layout`, and `NAME_ABBREVIATIONS` is deleted.
    Supersedes decision 16, which pinned 28 of the 71 names to a hand-kept abbreviation table -
    which pins each of those names to a copy of itself and lets a name mistyped the same way in
    both places through.
    Evidence: the extraction recovers all 77 rows of chapters 5, 6 and 11-13, including names set
    over two or three lines and across a page break; all 71 of gamut's rows agree with it.
    `pdftotext` is a system package `mise.toml` does not provision, so - as the record's decision 6
    allows - the extraction runs out of band: `tests/data/extract-iim-names.py` writes
    `tests/data/iim-4.2-dataset-names.tsv`, the artefact is committed, and the test reads it back.
    Renaming 2:92 to "Sublocation" now fails.
    Note: one of the four statements is a pushed commit message (f9b03b9d) and cannot be edited
    without rewriting published history, which this run may not do. The other three - the code
    comment, `STATUS.md` and this body - are corrected.
    Reverses: restore the hand table and the premise.

25. Taken: the array setters keep every member the caller passes, including one carrying no field
    at all, and an existing array's RDF container kind is kept rather than rewritten to `rdf:Bag`.
    Supersedes decision 18, which had them skip an empty member.
    Evidence: skipping shifted every later member's index, so `image_regions()` followed by
    `set_image_regions()` was not the identity, and it disagreed with `put_nested_array`, which
    writes the same kind of array one level down inside `rbVertices` and keeps it. Idempotence is
    the contract the module states. Only an empty slice removes the property.
    Reverses: restore the filter and write `rdf:Bag` unconditionally.

26. Taken: an `rdf:Alt` handed to a `list` helper is retained verbatim, not flattened into a Bag.
    Supersedes decision 19, and states what that decision got wrong: it recorded the leniency as
    "not a loss - the items are read rather than dropped". That evidence is false. Executed, the
    old code turned `Alt[x-default:"A", fr:"B"]` into `Bag[Simple("A"), Simple("B")]`, destroying
    both language tags. It is a loss, and decision 22's rule retains it without a special case.
    The frozen entry 19 is left as written, per the round-4 record.
    Reverses: restore the leniency.

27. Taken: the model is NOT widened to carry a value it cannot reproduce; such a field reads as
    absent and is retained, and the open question is filed rather than decided here.
    Evidence: round-4 record decisions 3 and 4. Carrying qualifiers, or a URI kind, or the whole
    language-alternative list, is a larger change than issue #422's scope and would still leave the
    general case open. Filed as #609.
    Reverses: widen the projection's value types.

28. Taken: the enumeration required by round-4 decision 9 is DERIVED from the module's shape
    corpus, not written by hand, and published in this body and in the module docs.
    Evidence: `retention_covers_every_shape_the_typed_read_parses_but_cannot_write_back` computes
    the list at test time - a shape is in it exactly when the typed read parses a value out of it
    and the reader still leaves the field alone - and asserts the thirteen labels. A hand-written
    list falls out of date; this one fails the gate when it does.
    Reverses: replace it with a literal list.

29. Taken: the reproduction relation is pinned clause by clause, one test per clause, rather than
    by the module's round trips alone.
    Evidence: the CI mutation shards on this branch reported seven surviving mutants, all inside
    the relation - a URI matched any URI, a structure matched any structure of the same field
    count, a qualifier list matched any list of the same length, and every qualifier was treated as
    a language tag. Each survivor turns a field the writer cannot reproduce into a field marked
    consumed, which is exactly the destruction decision 22 exists to stop, and the module's round
    trips could not see any of them because the writer never emits a shape it did not just build.
    `mise run mutants-diff` (base `origin/master`) now reports 152 mutants, 144 caught, 8 unviable,
    0 missed.
    Reverses: fold the four tests back into the round trips.

The fifth review round's decision record, verbatim as this lane received it:

1. F-1: apply the same rule one level up. Retention, not merging - a setter that merges would
   surprise a caller who expects it to replace, and the structure-internal rule already establishes
   the shape. Then pin it with a generated sweep at that level, mirroring the 2 420-case cross
   rather than a hand list: the reviewer's own design question 4 is the reason, and your 13-row
   table is only as complete as the shapes someone thought of. Reverses: restore the ungated
   accessors.
2. Design question 2: preserve the container kind. `Seq` surviving while `Alt` does not is an
   accident of which two shapes had tests, and the structure-internal path already gets this right.
3. Design question 3: take the same first-match rule at the top level that a structure already
   uses - two fields of one name are neither read nor lost. Consistency across the boundary is the
   whole point of this round.
4. Correct the two false claims - the "nothing is at risk at the top level" sentence and the
   identity claim on the array writer - and say what is true instead.
5. F-3: append a correction naming decision 23. Whitespace normalisation of a number *is* accepted;
   the code is right and the record's wording is narrower than the code. Do not edit the frozen
   entry.
6. F-4: state the residual accurately. The claim is not flatly true; say what fails, what passes,
   and that the generated file's banner is what carries the rest. Design question 5 - having CI
   re-derive the table when the extraction tool is present - is declined here and filed. It would
   close the gap and it is a conditional CI step added at the close of a loop; file it with the
   evidence and let a human take it.
7. F-2: record it in the unresolved notes. It is dormant, it is the one clause that would silently
   survive if a writer ever reordered, and it needs no test today. Do not manufacture one.

Appended by this lane while delivering that record:

30. Taken: the reproduction rule decides the top-level property too, by RETENTION - the read
    reports a value only when the property the setter will emit reproduces the property that was
    read, and the setter removes a property only when the read reported one.
    Evidence: executed on a serialized packet, read -> set-back through the four accessor pairs
    destroyed seven shapes a file's producer can put there: an array member that is not a
    structure, a qualifier on the property, a qualifier on an `rdf:li`, an `rdf:Alt` rewritten to
    `rdf:Bag`, a bare structure rewritten to a Bag, an array or structure holding nothing at all,
    and a qualifier on the contact-info property. `rdf:Seq` was the only survivor, and only because
    a test happened to pin it. Inside a structure the retention list is the type's `other` field;
    at the top level it is the graph itself, so retention needed no new storage - only that the
    setter stop removing what the read never reported.
    Rejected: a setter that merges what it read back in - a caller that calls a setter expects it
    to replace, and the structure-internal rule already establishes retention as the shape.
    Reverses: restore the ungated accessors.

31. Taken: an array setter keeps the RDF container kind the property already carries - `rdf:Alt`
    and `rdf:Bag` as well as `rdf:Seq` - and the test that pinned `Seq` alone now covers all three.
    Evidence: `Seq` surviving while `Alt` did not was an accident of which two shapes had a test,
    not a decision; the structure-internal path already treats the container kind as part of the
    value (XMP Part 1 §6.3.4).
    Reverses: write `rdf:Bag` unless the property is already a `Seq`.

32. Taken: two top-level properties of one name are neither read nor lost.
    Evidence: only one of the two could be written back, which is exactly why `Reader::read`
    already refuses two fields of one name inside a structure. Consistency across that boundary is
    the round. The graph is transparent data and the XMP reader does not enforce uniqueness, so the
    shape is reachable from a file.
    Rejected: reading the first match - it reports a value whose setter would then leave the graph
    disagreeing with what was read.
    Reverses: read the first match.

33. Taken: the top-level rule is pinned by a GENERATED cross - four accessor pairs x fifteen value
    shapes x four qualifier lists, 240 cases, each asserting that the whole graph comes back - not
    by extending the thirteen-row table.
    Evidence: the shapes this round found were not on that table, which is the argument against a
    hand list; and the sweep carries an unrelated property beside the one under test, so "the
    neighbour survived" needs no second sweep. The cross size is asserted, so a shape list that
    silently empties fails.
    Reverses: replace it with a hand list.

34. Taken: design question 5 - a CI step that re-derives the dataset-name table where `pdftotext`
    is present - is declined in this round and filed as #623.
    Evidence: it would close a real gap, and it is new tooling in a workflow file added at the
    close of a review loop rather than a repair to the change under review. The issue carries the
    evidence, the candidate answer and the two sub-questions (provision `pdftotext` or keep the step
    conditional; whether the extraction is stable across poppler versions).
    Reverses: add the step and close #623.

35. Correction, naming decision 23. Decision 23 records as rejected: "allowing whitespace or
    container-kind normalisation as `the same value`". That is narrower than the code for the
    whitespace half, and the code is right. `same_number` trims before parsing, so ` 0.50 ` and
    `0.5` reproduce each other, and `reproduction_is_equality_apart_from_the_two_re_spellings`
    asserts exactly that. Leading and trailing whitespace is not part of an XMP `Real`'s value
    (Part 1 §8.2.1), so removing it is a re-spelling of the same value and is idempotent - the same
    ground decision 23 gives for the number's lexical form. The container-kind half of that rejected
    clause stands and is unchanged. The frozen entry 23 is left as written.
    Reverses: make `same_number` compare untrimmed text.

36. Correction, naming decision 24 and the `STATUS.md` sentence it produced. Decision 24 concludes
    "Renaming 2:92 to `Sublocation` now fails", and `STATUS.md` stated "A mistyped name fails there
    even if it were mistyped the same way twice". Both are half-true. Renaming in `KNOWN_TAGS`
    alone does fail. Renaming in `KNOWN_TAGS` *and* in the committed
    `tests/data/iim-4.2-dataset-names.tsv` passes, because the guard compares the table against the
    artefact and nothing re-derives the artefact from the PDF. What carries that residual is the
    artefact's own never-hand-edit banner, not a gate. `STATUS.md` now says so; the frozen entry 24
    is left as written, and the gate that would close it is #623 (decision 34).
    Reverses: nothing - this is a statement of fact, not a change of behaviour.

37. Correction, naming decision 11 (round 2, restated). Decision 11 records that reading tolerates
    a bare structure written where the standard puts an array of structures, on the evidence that
    "writing still emits the standard array, so a read-modify-write normalises rather than
    propagates". Executed, that normalisation is a destruction: the bare structure the file carried
    is gone from the graph. Under decision 30 the shape reads as absent and the property survives
    untouched - which is what `rCtype` one level down has always done. The two false claims the
    round-5 record names are the module-doc sentences that justified the leniency ("Nothing is at
    risk there, because reading a property never rewrites it") and the array writer's "so that
    reading an array and setting it back is the identity"; both are module documentation rather
    than record entries, and both are corrected where they stand.
    Rejected: keeping the lenient read by teaching the setter to write a bare structure back - it
    would make the top level MORE lenient than a structure, reopening the very asymmetry this round
    exists to close, and it would write a non-standard form a caller never asked for.
    Reverses: read a bare structure as a one-element array again.

38. Correction, naming decision 32 above — this round's own entry, corrected before anyone acted
    on it. Decision 32's evidence says "the XMP reader does not enforce uniqueness, so the shape is
    reachable from a file". **That half is false.** `gamut_xmp`'s reader assembles a packet through
    `XmpMeta::set`, which replaces a property of the same (namespace, name), so a *parsed* packet
    never carries two top-level properties of one name. What is true, and is the reachability the
    decision needs: `XmpMeta::properties` is a public `Vec` and the model documents the graph as
    transparent data whose canonical-form invariants are maintained by the accessors rather than
    enforced by construction, and `PhotoMetadata::from_xmp` preserves whatever the graph it filters
    holds — so a caller that assembles or merges a graph by pushing can produce the shape, and a
    typed pair must not be the thing that then loses half of it. The decision itself is unchanged;
    only its stated route to the shape is narrower than it claimed.
    Reverses: nothing — this is a statement of fact.

Unresolved review notes

  • photo_metadata::lang_alt reads the first alternative, not the x-default one. It is master
    code, not this pull request's, and it does not destroy the other languages the way the extension
    helper did (see decision 20). Anyone changing it should give it the same xml:lang match.
  • The retained-fields collection is a public Vec<XmpProperty> on all seven types. Whether it
    should instead be reached through accessors, as AGENTS.md's C-portability convention suggests
    for a payload, is open and filed as gamut-iptc: should an extension structure's retained fields be a public field or reached through accessors? #591 rather than decided here.
  • A field the projection cannot express reads as absent. Nothing is lost — the field is
    retained verbatim and survives a read-modify-write — but the typed view does not report the
    value either. Whether the model should widen to carry it is filed as gamut-iptc: an extension field the projection cannot express reads as absent — should the model widen? #609 (decision 27).
  • to_xmp is infallible and silently omits a value the XMP type cannot hold. Whether emission
    should be fallible instead is filed as gamut-iptc: should extension structure emission be fallible? #573.
  • One of the four false "not machine-readable" statements is in a pushed commit message
    (f9b03b9d). It cannot be corrected without rewriting published history, which this run may not
    do; the correction is recorded in decision 24 instead.
  • The ordering clause of the reproduction relation has no falsifier and no mutant. same_array
    compares two arrays item by item in order, so a writer that reordered an array's items would not
    reproduce what was read. No writer reorders, and cargo-mutants generates no mutation that
    reorders, so the clause is currently an equivalent mutation: nothing can distinguish it from a
    version that compared the items as a set. It is dormant rather than wrong — an array's items are
    ordered (XMP Part 1 §6.3.4) and the clause is the correct statement — but it is the one clause
    that would silently survive if a writer ever did reorder. No test is manufactured for it here;
    anyone adding a writer that reorders should bring the falsifier with it.
  • KNOWN_TAGS is data and generates no mutants, so the mutation gate says nothing about it in
    either direction. Its defence is the two drift guards described under Validation, and the residual
    in the name guard is gamut-iptc: nothing re-derives the committed IIM 4.2 dataset-name table from the standard #623 (decisions 34 and 36).

Justin Chung and others added 10 commits September 9, 2026 23:40
The tag table covered only the structural record-1 datasets and the
record-2 datasets IPTC Photo Metadata maps to XMP — 26 of the 71 datasets
exiv2 documents. Transcribe the complete Envelope and Application dataset
definitions of IPTC-IIM 4.2 chapters 5 and 6 from references/iptc/iim-4.2.pdf:
14 record-1 and 56 record-2 entries with their names, repeatability and
value-field octet maxima.

The table is descriptive only — no FIELD_MAP row references a newly named
dataset — so reading, merging and writing are unchanged and every unmodeled
dataset still round-trips byte-exact.

2:202 ObjectData Preview Data and every dataset of records 7-9 stay
unmodeled: IIM 4.2 states no determinate octet maximum for them (256000
octets for 2:202, "a binary number" of unstated width for the rest), and
IimTagInfo::max_octets can only state one.

Refs #422
The structured Iptc4xmpCore:CreatorContactInfo and the IPTC Extension
structures had no typed model and reached callers only as raw gamut-xmp
values. Add `extension`, a typed projection over that graph in the shape
gamut-exif's GpsInfo already uses - from_xmp/to_xmp per structure plus one
accessor pair on PhotoMetadata:

- CreatorContactInfo (Iptc4xmpCore:CreatorContactInfo)
- ImageRegion (Iptc4xmpExt:ImageRegion), with RegionBoundary,
  RegionBoundaryPoint and Entity
- ArtworkOrObject (Iptc4xmpExt:ArtworkOrObject)
- Licensor (plus:Licensor)

Field identities, container kinds and language-alternative shapes come from
the IPTC Photo Metadata Standard 2025.1 and its machine-readable technical
reference. An ImageRegion keeps every property it does not model verbatim,
because the standard lets a region carry any other metadata property.

Every one of these properties is XMP-only - none carries an IIMid, so none
is in FIELD_MAP - and therefore can never be an IIM/XMP conflict; that is
documented on the types rather than given a new policy knob, and
ConflictPolicy is unchanged.

The PLUS namespace joins IPTC_NAMESPACES, since the IPTC Extension defines
plus:Licensor and its siblings there and PhotoMetadata::from_xmp would
otherwise drop them; xmp: deliberately does not, being general-purpose.

Refs #422
…eference

tests/techreference.rs already re-derived the IIM<->XMP mapping and the octet
limits from references/iptc/iptc-pmd-techreference_2025.1.json. Extend it to
the typed structures: a fully-populated instance of each is converted with
to_xmp and its emitted field identities compared against the XMPid set the
JSON's ipmd_struct entry defines, so an invented, missed or mis-prefixed field
fails loudly rather than drifting. The four top-level properties are pinned to
the ipmd_top XMPid and structure type the reference names, and their absence of
an IIMid - what makes a structured property unable to conflict with the legacy
carrier - is pinned there too rather than only documented.

tests/oracle.rs gains an exiv2 differential over a stream spanning the Envelope
record and the Application datasets outside the XMP-mapped subset; the existing
fixture is record 2 only, so this is the first record-1 leg.

Refs #422
STATUS.md and README.md still said no IPTC Extension structure had a typed
model and that the tag table covered only the structural and PMD-mapped
datasets. Replace both with what the crate now does, and state the two
deferrals that remain with the issues tracking them: the eleven unmodelled
Extension structures (#538) and the IIM datasets with no spec-stated octet
maximum (#539).

Also correct the records 3-9 deferral: IIM 4.2 defines no datasets for records
3-6 at all, so there is nothing there to name.

Refs #422
Entity::from_xmp was asserted only for the non-structure case, which a
mutation replacing its body with None satisfies - the only mutant the
incremental gate missed. Every path that reaches an Entity through an
ImageRegion goes via from_fields, so nothing else exercised the public
value-level conversion with a structure. Add the round-trip.

Refs #422
The tag table's stated criterion is "every dataset IIM 4.2 gives a
determinate octet maximum", but 7:10 Size Mode was excluded with the
records 7-9 datasets whose value is "a binary number" of unstated width.
IIM 4.2 Ch. 11 defines it as "Mandatory, not repeatable, one octet" — a
determinate maximum, and trivially within `max_octets`'s `u16` — so the
criterion did not hold and the four places that stated it disagreed with
each other on how many datasets it excluded.

Name it, and restate the criterion the same way everywhere: a dataset is
named when the spec states a maximum that is determinate and fits a
`u16`. Exactly six are not — 2:202 (256000 octets) and 7:20, 7:90, 7:95,
8:10, 9:10 — and the boundary case is now pinned by the test rather than
only described.
`iim-4.2.pdf` is not machine-readable, so the 70 record-1/2 rows are a
hand transcription with nothing but ordering and uniqueness to catch a
slipped digit: changing one maximum from 128 to 7 left the whole crate
suite passing, because no test consults the table for a dataset outside
the ~20 the PMD reference maps.

exiv2 — already this crate's differential oracle — carries an
independent transcription of the same two chapters. Parse it out of the
vendored sources and compare every row's octet maximum, repeatability
and value kind. All 70 rows agree today; the one deliberate divergence
(1:90 Coded Character Set is ISO 2022 escape sequences, not the string
exiv2 calls it) is pinned as an exception rather than merely documented,
and the completeness leg names 2:202 as the only row exiv2 documents
that a `u16` maximum cannot express.
`ArtworkOrObject`, `Licensor` and `CreatorContactInfo` had no retention
list, so their setters replaced the whole graph property: a structure
carrying a vendor extension came back from a read-modify-write with that
property gone, silently. Only `ImageRegion` kept what it could not name,
because the standard sanctions extras there explicitly — but a
real-world file carries them anywhere.

Give all three the same `other` list `ImageRegion` has, filled by
`from_xmp` and re-emitted verbatim by `to_xmp`. The types are
`#[non_exhaustive]`, so the field is additive.

Emitting the retained set now skips any field whose name the model
already owns. The list is public, so a caller could put a modelled name
in it and produce a structure with two fields of one name — ill-formed,
and non-idempotent, since reading it back drops the second. The modelled
value stays the authority.
Files in the wild write a single structure where the standard puts a Bag
of them — one `plus:Licensor`, one `Iptc4xmpExt:rCtype` — and gamut read
those as nothing at all, because the accessor asked the graph for an
array and took `None` for empty.

Read it as the sequence's single element, at both levels, through one
helper. Writing is unchanged and still emits the Bag, so a
read-modify-write normalises the shape rather than propagating it. That
is the crate's posture elsewhere: strict on write, honest and lenient on
read.
Two emitters could put something into the graph that is not a value of
the property's type, both reachable from safe public API.

A non-finite coordinate was written as the text `NaN`, `inf` or `-inf`,
none of which is an XMP `Real`; nothing reads it back as a number. The
emitter is infallible by design and matching its sibling's shape matters
more than a newtype here, so the value is skipped and the omission is
documented. Whether emission should become fallible instead is filed
separately.

And a `CreatorContactInfo` with nothing in it wrote an empty structure,
which a reader reports as "present but blank", where the three array
setters remove the property. All four now agree: nothing to say removes
the property.
The four structured-property setters judged emptiness differently: an empty
block removed the property, but an empty *member* of a bag was written as a
blank element that a reader reports as present-but-blank. The array setters now
skip a member with no field at all, and remove the property when nothing is
left, matching the setter that already did.
A field whose name a structure models but whose value the typed reader rejects
was neither parsed nor retained: it read as absent, and the excluded-because-
modelled rule then dropped it on write, so an identifier carrying a structure
and a coordinate carrying non-numeric text both vanished from the graph.

Retention is now decided by consumption rather than by a list of modelled
names. A field the read took no value from stays in the type's `other` list
whether the model names it or not, which also gives the three nested structures
- RegionBoundary, RegionBoundaryPoint and Entity - the retention the four
top-level ones already had, so a vendor field inside a boundary survives as one
beside it does. On write, a retained field is dropped as a namesake only when
the modelled field was actually emitted; when it was not, it is the only copy
of that name and is written.
The helper took the first entry of an alternative list and re-emitted it
labelled `x-default`, so a list a writer had ordered with a French entry first
came back with the French text standing in for the default and the real default
gone. The entry is now found by its `xml:lang` qualifier, compared
case-insensitively, as XMP Part 1 8.2.2.4 requires and as
`XmpMeta::get_lang_alt` already does for a top-level property.

A list with no default entry at all is not read: it reads as absent and, having
consumed nothing, is kept verbatim, so the languages it does carry survive
instead of one of them being relabelled.
The drift guard compared octet maxima, repeatability and value kind but not
names, because the C++ parse threw string literals away before splitting a row
into fields - so a mistyped dataset name survived the whole suite. Literals are
now indexed rather than discarded, which keeps a description's commas out of
the field split while leaving the title readable.

exiv2 titles 28 of the 70 datasets more briefly than IIM 4.2 names them
("Caption" for "Caption/Abstract"). Those rows pin both spellings, in the shape
the octet-limit exceptions already use, so every name in the table is pinned to
something: 42 to exiv2's title directly, 28 to a self-invalidating pair, and
7:10 Size Mode - which exiv2 does not carry - to the spec's own spelling.
The drift guard now compares dataset names as well as octet maxima,
repeatability and value kind, and retention covers a modelled field whose value
the typed read cannot take, not just a field the model does not name.
A typed read consumed a field whenever it could parse a value out of it,
which is a claim about the read, not about the round trip. Three shapes
parsed cleanly and were then written back as something else, so the field
was neither modelled nor retained: a URL held as `rdf:resource` came back
as element text, a value's qualifiers were dropped, and a coordinate stated
as `NaN`, an infinity or `1e400` vanished entirely - `put_number` refuses to
write a value the XMP `Real` type has no form for, and by then the field had
already been marked consumed.

Consume a field only when the property the writer will emit for the value
read reproduces the field that was read - value, container kind, qualifiers
and all. Every `parse_*` now sits beside the `*_value` that writes its
result, and the reader compares against that very value, so the two cannot
drift apart. Two lexical re-spellings still count as reproducing a field,
because both are idempotent and value-preserving: a number's form, and the
case of an `x-default` language tag.

The rule subsumes the read-side special cases and closes the shapes nobody
had named: an `rdf:Alt` or `rdf:Seq` handed to a list field, an array item
of a kind the model skips, a bare structure where an array belongs, and two
fields of one name are all retained now instead of being normalised away.
`put_other` no longer drops a retained field because an earlier *retained*
field shares its name - only a modelled one it was actually emitted beside
can displace it.

The one-field shape corpus states the law over every shape at once, and the
enumeration of what retention newly covers is derived from it rather than
listed by hand.
The array setters dropped a member that carried no field at all, which
shifted every later member's index, so `image_regions()` followed by
`set_image_regions()` was not the identity; `put_nested_array`, writing the
same kind of array one level down inside `rbVertices`, kept it. Idempotence
is the contract the module states, so the two writers now agree on keeping
it: nothing to say is a member's business, not the writer's, and only an
empty slice removes the property.

They also rewrote the property as an `rdf:Bag` whatever it had been, so a
caller who wrote an `rdf:Seq` lost the one thing a Seq states that a Bag
does not. The setter has the existing property in front of it, so it keeps
that kind and falls back to the standard's Bag for anything that is not
already an array.
`iim-4.2.pdf` was called not machine-readable, and the tag table's names
were pinned to exiv2's titles on that basis. exiv2 abbreviates 28 of the 70
datasets it shares with gamut, so those rows had to pin both spellings from
a hand-kept table - which pins each name to a copy of itself and lets a
name mistyped the same way in both places through.

The premise is false. The standard sets every DataSet's name in a column of
its own, and `pdftotext -bbox-layout` recovers it by position: all 77 rows
of chapters 5, 6 and 11-13, including the names set over two or three lines
and across a page break. All 71 of gamut's rows agree with it today.

`pdftotext` is a system package the toolchain does not provision, so the
extraction runs out of band: `tests/data/extract-iim-names.py` writes
`tests/data/iim-4.2-dataset-names.tsv`, the artefact is committed, and the
test reads it back. Renaming 2:92 to "Sublocation" now fails; before, only
a reviewer reading the PDF would have caught it.

exiv2 keeps the columns the standard states in prose - octet maximum,
repeatability and value kind - and loses the name comparison it could never
make well.
The crate docs described retention as covering "every field its typed read
took no value from", which is the read-side rule the extension module no
longer uses. Say what decides it: whether the property the writer will emit
reproduces the field that was read, and what the two remaining differences
are.
The retention rule reads whether the writer would give the field back, so
the relation that decides it needs a test that fails for that one reason.
Replacing the numeric comparison with a blanket "any two texts match" left
every round trip in the module passing, because the writer never emits a
different text for a field it did not re-spell.
The module lists what retention covers; the derived enumeration in the
tests covers thirteen shapes and the prose covered eleven. Plain text
written where a language alternative belongs, and an rdf:Bag written where
the model writes an rdf:Seq, were both missing.
The relation deciding retention was pinned only by its numeric clause, so
four of its parts were free to change without a test noticing: a URI
matched any URI, a structure matched any structure of the same field count,
a qualifier list matched any list of the same length, and every qualifier
was treated as a language tag and matched case-insensitively. Each of those
turns a field the writer cannot reproduce into a field marked consumed,
which is the destruction the rule exists to stop.

One test per clause, each failing for its own reason: the URI target, the
structure's fields as an unordered set, the qualifier list matched pairwise,
and the language tag as the one qualifier XMP Part 1 §8.2.2.4 matches
without regard to case - neither the `xml:` namespace alone nor the name
`lang` alone makes one.
The reproduction rule stopped at the structure boundary. Inside a structure a
field is consumed only when the property the writer emits for the value read
gives that field back; the four accessor pairs on `PhotoMetadata` applied no
such gate, and the setter is the other half of the pair a caller reads through.
Executed on a serialized packet, read then set-back silently dropped what a
file's producer had put there: an array member that is not a structure, a
qualifier on the property, a qualifier on an `rdf:li`, an `rdf:Alt` rewritten
to `rdf:Bag`, a bare structure rewritten to a Bag, an array or structure holding
nothing at all, and a qualifier on the contact-info property. Only `rdf:Seq`
survived, and only because a test happened to pin it.

The gate moves up: one `Projection` per property reads a value only when what
the setter will emit reproduces the property that was read, and the setter
removes a property only when the read reported one — the graph itself is the
retention list a structure keeps in its `other` field. An array setter now
keeps the container kind the property already carries, `rdf:Alt` as well as
`rdf:Seq`, and two top-level properties of one name are neither read nor lost,
as two fields of one name already were inside a structure.

Retention, not merging: a setter handed values still replaces the property, so
a caller that expects it to replace is not surprised.
A thirteen-row table of shapes is only as complete as the shapes someone thought
of, and the shapes this round found at the top level were not on it. So the pin
is generated, not listed: the four accessor pairs crossed with fifteen value
shapes and four qualifier lists — 240 cases — each asserting that reading the
property and writing what was read straight back gives the whole graph back,
the property under test and the unrelated one beside it.

The two tests that pinned a read-side skip with no writer opposite are restated
as what the rule now says, and the container-kind test covers all three kinds
rather than the one that had a test.
"A mistyped name fails there even if it were mistyped the same way twice" is
half-true. The guard compares `KNOWN_TAGS` against the committed extraction, and
nothing re-derives the extraction from the PDF, so a name mistyped identically
in both places passes. What carries that residual is the artefact's own
never-hand-edit banner, not a gate; the gate that would close it is filed as

The deferral list gains the top-level half of the fidelity rule, which now
matches the structure-internal half: a property the setter could not write back
reads as absent and is left untouched rather than rewritten.
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