feat(metadata): report remote provenance from dcterms:provenance - #503
Open
justin13888 wants to merge 4 commits into
Open
justin13888 wants to merge 4 commits into
justin13888 wants to merge 4 commits into
Conversation
C2PA 2.4 §11.5 has a claim generator whose manifest store lives outside the file add a `dcterms:provenance` key to the asset's XMP, its value the URL of that store; §15.5.3.1 lists the key among the places a validator looks and makes it external-only. gamut-xmp had no DCMI Metadata Terms entry, so such a property serialized under a synthesized `ns1` prefix rather than the `dcterms` one XMPCore's own registry knows. Register `WellKnownNs::DcTerms` (`dcterms`, `http://purl.org/dc/terms/`) in the registry and `ALL`, so the writer picks the conventional prefix and `from_uri` recovers the schema. The crate stays a namespace registry: what the property means is gamut-metadata's business. `WellKnownNs` becomes `#[non_exhaustive]`. It was exhaustively matchable, so every schema the format and metadata crates go on to need would have been a major bump; the attribute pays that once, here, where a major is already due. No workspace crate matches on it. The oracle test serializes a `dcterms:provenance` URI value and asserts that exiv2 (Adobe XMPCore) reads it back under `Xmp.dcterms.provenance`, the key its schema registry defines, and that gamut re-parses XMPCore's output to the same URL; the unit test pins the URI and prefix strings exactly, since `/dc/terms/` and `/dc/elements/1.1/` share a vendor path. BREAKING CHANGE: `WellKnownNs` is `#[non_exhaustive]`; an exhaustive `match` on it needs a wildcard arm. Refs #449
A file with no embedded manifest store but a `dcterms:provenance` URL in its XMP has Content Credentials (C2PA 2.4 §11.5); a caller testing `Metadata::c2pa.is_some()` was told, confidently, that it did not. And because §15.5.3.1 reserves the key for external manifests, a file may carry both an embedded store and a URL to a newer one, so a boolean is the wrong answer too. Add `ProvenanceState` — `None`, `Remote(url)`, `Embedded`, `EmbeddedAndRemote(url)`; `#[non_exhaustive]`, `#[repr(u8)]` with explicit append-only discriminants, plus `is_embedded()` / `remote_url()` so a caller need not match — and `Metadata::provenance()`, a computed lens over the two independent sources: `c2pa.is_some()` and the simple `dcterms:provenance` property of the XMP graph, read with the same `get_text` accessor gamut-iptc uses for its properties (so both the element-text and `rdf:resource` forms count). Neither source suppresses the other. An empty value is treated as no URL: the spec makes the value a URI reference, which an empty string is not. gamut never resolves the URL — that is a validator's job and a network operation — and the HTTP `Link` header route of §15.5.3.2 is recorded as deliberately out of scope: a header is a property of a transfer, not of the file's bytes. The facade tests drive all four states through `Metadata::from_blocks` with real `MetadataBlock::Xmp` packets and a `MetadataBlock::C2pa` store; the inline tests pin the empty-value rule, namespace discrimination (`dc:provenance` is a different property) and the two accessors over every variant. Closes #449
`ProvenanceState` carried `#[repr(u8)]` with explicit discriminants and a `Default` of `None`. Neither earns its place. A data-carrying enum's tag is not observable from safe code and `String` is not FFI-safe, so the repr promised a C layout nothing can consume; the C-portable surface of the type is `is_embedded()` and `remote_url()`, which already satisfy AGENTS.md's "payloads reachable through accessors". And a report type defaulting to a confident "no provenance" is the wrong default for a value that is always computed from the file — `Metadata::provenance()` never needs one. Remove both, and the test that pinned the default. The docs now say why the accessors are the portable surface and why there is no `Default`. Refs #449
…bsent
The empty-value rule was there because §11.5 makes the value a URI reference
and `Remote("")` hands a caller nothing to fetch. The same reason applies to
a whitespace-only value, which slipped through as `Remote(" ")`, and to a
padded value, which kept its padding around an otherwise good URL.
Trim surrounding whitespace before the emptiness check, so whitespace-only
reads as no URL (`None`, or `Embedded` when a store is present) and a padded
URL comes back clean. Pinned by a unit test beside the empty-value one.
Also document, on `Metadata::provenance()`, that if a non-canonical graph
carries the property twice the first occurrence wins — inherited from
`XmpMeta::get`, not a choice made here.
Refs #449
This was referenced Sep 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
feat(xmp)!:): registersWellKnownNs::DcTerms— prefixdcterms, URIhttp://purl.org/dc/terms/(DCMI Metadata Terms) — in the schema registry andALL, so adcterms:provenanceproperty serializes under the prefix Adobe XMPCore's own registry knows instead of a synthesizedns1.WellKnownNsbecomes#[non_exhaustive](breaking, paid once here where a major is already due; no workspace crate matches on it). The crate stays a namespace registry: it does not interpret the property.feat(metadata):, thenrefactor(metadata):andfix(metadata):from review): addsProvenanceState—None/Remote(url)/Embedded/EmbeddedAndRemote(url),#[non_exhaustive], noDefault, withis_embedded()/remote_url()as its C-portable accessor surface — andMetadata::provenance(), a computed lens (stored nowhere) over two independent sources:c2pa.is_some()and the simpledcterms:provenanceXMP property, read with the sameget_textaccessor gamut-iptc uses. Neither source suppresses the other. The value is trimmed; an empty or whitespace-only value is treated as no URL; with duplicate properties the first wins (XmpMeta::get).dcterms:provenance(a URI reference) to the asset's XMP, and says the mechanism is only for external manifests; §15.5.3.1 lists the key among the places a validator looks when nothing is embedded. A caller testingc2pa.is_some()was told, confidently, that such a file has no Content Credentials — and a boolean would be wrong too, since a file may carry both sources.Linkheader route of §15.5.3.2 is documented as out of scope: a header is a property of a transfer, not of the file's bytes. No new carrier;C2paPolicyunchanged.This is an unattended run. No human approved the plan; the decision record below is what a human reads afterwards.
Validation
Run from the lane worktree; heavy gates inside
systemd-run --user --scope -q --slice=agents.slice -p MemoryMax=16G -p MemorySwapMax=0 -- env CARGO_BUILD_JOBS=2 CMAKE_BUILD_PARALLEL_LEVEL=2 TMPDIR=/var/mnt/scratch/golem/tmp prlimit --as=12000000000 …(prlimitstands in forulimit -v 12000000; same 12 GB address-space limit).CARGO_BUILD_JOBS=2 cargo test -p gamut-xmp --all-features— pass: 94 unit, 4 golden, 7 oracle (incl. newdcterms_provenance_reads_back_from_xmpcore_under_the_dcterms_keyagainst exiv2/XMPCore), 5 roundtrip, 3 doctests.CARGO_BUILD_JOBS=2 cargo test -p gamut-metadata --all-features— pass on the final tree: 13 unit (5 new), 15 extensions, 4 provenance (new), 17 roundtrip, 7 doctests (1 new).__CARGO_TEST_ROOT=<worktree> mise run fmtthenmise run fmt-check— pass (the prefix is the documented workaround for cargo walking past a nested worktree to the primary checkout's manifests).mise run check-tests— pass.mise run check-commits— pass, "no errors in 2 commits".CARGO_BUILD_JOBS=2 cargo clippy -p gamut-xmp -p gamut-metadata --all-targets --all-features -- -D warnings— pass on the final tree.mise run lint(scope above) — pass twice: 7m02s on the tree before the pre-commit review edits, and again on the final tree (f809390, incremental, exit 0).mise run test(scope above) — pass twice: 203 suites, 3,779 tests, 0 failed, 8m57s before the review edits; 203 suites, 3,778 tests, 0 failed on the final tree (one fewer test: the redundant facade test removed in decision 11).RUSTDOCFLAGS="-D warnings" cargo doc -p gamut-xmp -p gamut-metadata --no-deps --all-features— fails, pre-existing: sixredundant_explicit_linksatcrates/gamut-metadata/src/embed.rs:186-197, a file this PR does not touch; rustdoc is not a repository gate (mise run lintis clippy-only). Every changed file is rustdoc-clean.mise run mutants-crate gamut-metadata --shard 0/1(scope above,timeout 3600) — pass: 56 mutants tested in 58s, 50 caught, 6 unviable, 0 missed. (--shard 1/1is rejected by cargo-mutants — shards are zero-indexed — and produced one aborted run.)mise run mutants-crate gamut-xmp --file crates/gamut-xmp/src/namespace.rs --shard 0/1(scope above,timeout 3600) — pass: 8 mutants tested in 2m, 6 caught, 2 unviable, 0 missed (decision 8 explains the file narrowing).check-release-deps/check-ffi-features(noCargo.tomltouched),check-ffi-header(gamut-ffi exposes no gamut-metadata type),coverage(the new module is fully reached by its tests).After the review repairs (
ce3e91a,d6fd0a0; headd6fd0a0):CARGO_BUILD_JOBS=2 cargo test -p gamut-metadata --all-features— pass: 13 unit (incl. newprovenance_trims_the_url_and_treats_whitespace_only_as_no_url;default_is_noneremoved withDefault), 15, 4, 17, 7 doctests.CARGO_BUILD_JOBS=2 cargo clippy -p gamut-metadata --all-targets --all-features -- -D warnings— pass.__CARGO_TEST_ROOT=<worktree> mise run fmt/mise run fmt-check— pass;mise run check-tests— pass;mise run check-commits— pass, "no errors in 4 commits".mise run mutants-diff(scope above,timeout 3600; baseorigin/master) — pass: 12 mutants tested in 2m, 11 caught, 1 unviable, 0 missed.mise run test(scope above) — pass: 203 suites, 3,778 tests, 0 failed.Risks and rollout
WellKnownNsis#[non_exhaustive]; an exhaustivematchdownstream needs a wildcard arm. No workspace crate matches on it (only.uri()calls ingamut-iptc/src/schema.rs). The break reaches consumers who match ongamut_iptc::…,gamut_metadata::xmp::WellKnownNsorgamut::xmp::WellKnownNsthrough thepub use gamut_xmp as xmpre-exports — see decision 10 and the review notes.Metadatawas already#[non_exhaustive].provenance()reads one XMP property and oneOption; no I/O, no allocation beyond cloning the trimmed URL string. Operational risk: none.ce3e91a,d6fd0a0): the repr/discriminants andDefaultremoved fromProvenanceStatenever shipped (the type is new in this PR), so nothing downstream changes; the whitespace trim only affects values no URI reference can carry.Issue
Closes #449
Decisions taken
Appended by the lane, in the record's shape:
Unresolved review notes
references/c2pa/README.mdclause map: thegamut-metadatarow does not yet list §11.5 / §15.5.3.1 / §15.5.3.2, andgamut-xmp(which now cites §11.5 innamespace.rs) has no row. Outside this PR's manifest (decision 13).#[non_exhaustive]break reaches consumers through thepub use gamut_xmp as xmpre-exports in gamut-iptc, gamut-metadata and the umbrella (decisions 10 and 15). This lane adds noBREAKING CHANGEfooter in those crates; check the outcome against release-plz's semver check at release time.redundant_explicit_linksincrates/gamut-metadata/src/embed.rs:186-197, visible only underRUSTDOCFLAGS="-D warnings", which is not a repository gate.