Skip to content

feat(metadata): report remote provenance from dcterms:provenance - #503

Open
justin13888 wants to merge 4 commits into
masterfrom
feat/449-xmp-dcterms-provenance
Open

justin13888 wants to merge 4 commits into
masterfrom
feat/449-xmp-dcterms-provenance

Conversation

@justin13888

@justin13888 justin13888 commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • gamut-xmp (feat(xmp)!:): registers WellKnownNs::DcTerms — prefix dcterms, URI http://purl.org/dc/terms/ (DCMI Metadata Terms) — in the schema registry and ALL, so a dcterms:provenance property serializes under the prefix Adobe XMPCore's own registry knows instead of a synthesized ns1. WellKnownNs becomes #[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.
  • gamut-metadata (feat(metadata):, then refactor(metadata): and fix(metadata): from review): adds ProvenanceStateNone / Remote(url) / Embedded / EmbeddedAndRemote(url), #[non_exhaustive], no Default, with is_embedded() / remote_url() as its C-portable accessor surface — and Metadata::provenance(), a computed lens (stored nowhere) over two independent sources: c2pa.is_some() and the simple dcterms:provenance XMP property, read with the same get_text accessor 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).
  • Why. C2PA 2.4 §11.5 recommends a claim generator whose manifest store is external add 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 testing c2pa.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.
  • Deliberately not done. gamut never fetches the URL (a validator's job and a network operation; the workspace ships neither). The HTTP Link header 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; C2paPolicy unchanged.

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 … (prlimit stands in for ulimit -v 12000000; same 12 GB address-space limit).

  • CARGO_BUILD_JOBS=2 cargo test -p gamut-xmp --all-featurespass: 94 unit, 4 golden, 7 oracle (incl. new dcterms_provenance_reads_back_from_xmpcore_under_the_dcterms_key against exiv2/XMPCore), 5 roundtrip, 3 doctests.
  • CARGO_BUILD_JOBS=2 cargo test -p gamut-metadata --all-featurespass 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 fmt then mise run fmt-checkpass (the prefix is the documented workaround for cargo walking past a nested worktree to the primary checkout's manifests).
  • mise run check-testspass.
  • mise run check-commitspass, "no errors in 2 commits".
  • CARGO_BUILD_JOBS=2 cargo clippy -p gamut-xmp -p gamut-metadata --all-targets --all-features -- -D warningspass 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-featuresfails, pre-existing: six redundant_explicit_links at crates/gamut-metadata/src/embed.rs:186-197, a file this PR does not touch; rustdoc is not a repository gate (mise run lint is 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/1 is 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).
  • Not run, by the lane rules: check-release-deps / check-ffi-features (no Cargo.toml touched), 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; head d6fd0a0):

  • CARGO_BUILD_JOBS=2 cargo test -p gamut-metadata --all-featurespass: 13 unit (incl. new provenance_trims_the_url_and_treats_whitespace_only_as_no_url; default_is_none removed with Default), 15, 4, 17, 7 doctests.
  • CARGO_BUILD_JOBS=2 cargo clippy -p gamut-metadata --all-targets --all-features -- -D warningspass.
  • __CARGO_TEST_ROOT=<worktree> mise run fmt / mise run fmt-checkpass; mise run check-testspass; mise run check-commitspass, "no errors in 4 commits".
  • mise run mutants-diff (scope above, timeout 3600; base origin/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

  • Breaking for gamut-xmp (2.0): WellKnownNs is #[non_exhaustive]; an exhaustive match downstream needs a wildcard arm. No workspace crate matches on it (only .uri() calls in gamut-iptc/src/schema.rs). The break reaches consumers who match on gamut_iptc::…, gamut_metadata::xmp::WellKnownNs or gamut::xmp::WellKnownNs through the pub use gamut_xmp as xmp re-exports — see decision 10 and the review notes.
  • Additive for gamut-metadata: new type, new method, new module; no existing signature changes. Metadata was already #[non_exhaustive].
  • Behavioural: provenance() reads one XMP property and one Option; no I/O, no allocation beyond cloning the trimmed URL string. Operational risk: none.
  • Review repairs (ce3e91a, d6fd0a0): the repr/discriminants and Default removed from ProvenanceState never 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

Issue 449 - gamut-xmp/gamut-metadata: report remote provenance from dcterms:provenance
Plan:     v1
Branch:   feat/449-xmp-dcterms-provenance
Base:     origin/master (6a75ec4)
Cause:    -
Touches:  gamut-xmp namespace.rs (+tests, docs); gamut-metadata metadata.rs + new provenance.rs + lib.rs (+tests, docs)
Will not: fetch any URL; model the HTTP Link header route (documented as deliberately out of scope); add a new carrier; change C2paPolicy
Lane:     parallel (root of the xmp/metadata stack: #421 and #420 branch from this head)
Settled:  S2 - semver via conventional commits; a breaking change is `feat(scope)!:` with a BREAKING CHANGE footer; S3 - docs/testing.md placement

Decisions taken.
1. Deliverable boundary
   Taken:    the whole issue - namespace + property + three-state lens on the facade; no split
   Filed:    -
2. Namespace
   Taken:    `WellKnownNs::DcTerms` (prefix `dcterms`, URI `http://purl.org/dc/terms/`) added to the registry and ALL; `WellKnownNs` gains `#[non_exhaustive]` in the same commit as `feat(xmp)!:` with `BREAKING CHANGE: WellKnownNs is #[non_exhaustive]` - it is exhaustively matchable today and #421 adds twelve more variants, so the break is paid once
   Rejected: leaving it exhaustive - forces a second major on #421; a free-standing constant outside the registry - the crate's convention is the registry
   Reverses: drop the attribute
3. The lens
   Taken:    `gamut_metadata::ProvenanceState` - `#[non_exhaustive] #[repr(u8)]` enum `None = 0`, `Remote(url) = 1`, `Embedded = 2`, `EmbeddedAndRemote(url) = 3` (four states: the two sources are independent and §15.5.3.1 says a file may carry both; a boolean or a two-state enum is exactly the collapse the issue forbids) - returned by `Metadata::provenance(&self) -> ProvenanceState`, reading `self.c2pa.is_some()` and the `dcterms:provenance` simple property of the XMP graph; `Remote` carries the URL as a `String`; the doc states gamut never resolves it
   Rejected: `Option<bool>`; a field on Metadata (derived state should not be stored)
   Reverses: collapse to three variants by folding EmbeddedAndRemote into Embedded
4. Where the property is read
   Taken:    in gamut-metadata over the `XmpMeta` graph using the same property accessor shape gamut-iptc uses; gamut-xmp only registers the namespace (it is "a namespace registry, not a validator")
   Reverses: add a typed `dcterms` accessor to gamut-xmp instead

Appended by the lane, in the record's shape:

5. Stale local branch ref
   Taken:    `git branch -M` over a leftover local `feat/449-xmp-dcterms-provenance` that pointed exactly at origin/master (6a75ec4) with zero commits, no remote counterpart and no PR (reflog: renamed from `worktree-agent-aeb15379626bf5f6d`, an earlier lane) - nothing was discarded
   Rejected: a differently-named branch - the record fixes the name
   Reverses: n/a
6. Empty `dcterms:provenance` value
   Taken:    treated as no URL (`.filter(|url| !url.is_empty())`); §11.5 makes the value a URI reference, which an empty string is not, and `Remote("")` would hand a caller something to fetch
   Rejected: surfacing `Remote("")`; trimming whitespace - the crate stores XMP values verbatim everywhere else, and unpinned normalisation invites drift
   Reverses: delete the filter and `provenance_treats_an_empty_dcterms_value_as_no_url`
7. Accessors on the lens
   Taken:    `ProvenanceState::is_embedded()` and `remote_url()` - AGENTS.md asks for payloads reachable through accessors, and `#[non_exhaustive]` bars downstream exhaustive matching, so the two underlying questions need an answer that is not a `match`
   Rejected: the bare enum only
   Reverses: delete the two methods and `provenance.rs`'s inline tests
8. Mutation scope
   Taken:    gamut-metadata whole crate (56 mutants, `--shard 0/1`); gamut-xmp narrowed to `--file crates/gamut-xmp/src/namespace.rs` (the only file changed; 8 mutants, all in the diff) - every one of the 284 gamut-xmp mutants links the exiv2 oracle, so the full crate is hours for files this PR does not touch, and CI's blocking gate is `--in-diff`
   Rejected: full gamut-xmp shards
   Reverses: `mise run mutants-crate gamut-xmp --shard i/n` over the remaining shards
9. Resource-limit incantation
   Taken:    `prlimit --as=12000000000 <cmd>` under the same `systemd-run … MemoryMax=16G` scope, in place of `sh -c 'ulimit -v 12000000; exec <cmd>'` - the worktree-isolation guard refuses any `sh -c`; the address-space limit is identical
   Reverses: n/a
10. Re-export cascade of the WellKnownNs break (review finding)
   Taken:    only the gamut-xmp commit is `!`-marked, per decision 2; gamut-iptc, gamut-metadata and gamut re-export `gamut_xmp` verbatim and take the break through release-plz's dependency bump, uniformly
   Rejected: `feat(metadata)!:` alone - gamut-iptc and the umbrella are in the same position and outside the manifest; marking one of three would misstate the others
   Reverses: `BREAKING CHANGE` footers on commits touching each re-exporting crate, so release-plz cuts a major for each
11. Redundant facade test (review finding)
   Taken:    dropped `remote_url_reads_the_rdf_resource_form_too` - it killed no gamut-metadata mutant; `XmpValue::text` already pins the `Uri`/`Simple` equivalence in gamut-xmp
   Reverses: restore the test
12. Spec-attribution and "newer manifest" wording (review finding)
   Taken:    docs now attribute "only for external manifests" to the §11.5 note and the lookup order to §15.5.3.1, say "recommends" rather than "has … add", and describe `EmbeddedAndRemote` as a report of what the file carries - a validator uses the embedded store and does not consult the URL (§15.5.2.1, §15.5.3.1) - instead of speculating the URL is "newer"
   Reverses: n/a (the previous wording was unsupported by the vendored text)
13. Clause map in references/c2pa/README.md (review finding)
   Taken:    not edited - outside the manifest, and the delivery does not need it; reported to the orchestrator as a manifest revision request
   Reverses: add `gamut-metadata` §11.5/§15.5.3.1-2 and a `gamut-xmp` §11.5 row
14. ProvenanceState layout and default - decided by the orchestrator on review (amends decision 3's `#[repr(u8)]`; the original text stands as written)
   Taken:    drop `#[repr(u8)]` and the explicit discriminants - a data-carrying enum's tag is not observable from safe code and `String` is not FFI-safe, so the C-portable surface is `is_embedded()`/`remote_url()`, which already satisfy AGENTS.md's accessor clause; drop `Default` - a report type defaulting to a confident "no provenance" is the wrong default and the value is always computed; trim surrounding whitespace in `dcterms:provenance` and treat a whitespace-only value as absent (the record's own reason for the empty-value rule, decision 6, applies), pinned by a unit test; document that with duplicate properties the first wins (inherited from `XmpMeta::get`)
   Rejected: a fieldless mirror enum carrying the repr - no consumer exists for it
   Reverses: restore `#[repr(u8)]` + discriminants and `#[derive(Default)]`/`#[default]`; remove `.map(str::trim)` and `provenance_trims_the_url_and_treats_whitespace_only_as_no_url`
15. Recorded without code change - decided by the orchestrator on review
   Q2 - Taken:    gamut-metadata, gamut-iptc and gamut inherit the `WellKnownNs` break through release-plz's dependency bump; no footer commits in other crates from this lane (outside the manifest); residual for the release reviewer to check against release-plz's semver check
        Rejected: `BREAKING CHANGE` footers on re-exporting crates from this lane
        Reverses: add those footers in a follow-up touching each re-exporting crate
   Q3 - Taken:    the `rdf:resource` form of the value is pinned transitively by `XmpValue::text` in gamut-xmp; no drift-guard test in gamut-metadata (it would kill no mutant; docs/testing.md)
        Rejected: a facade-level `rdf:resource` test
        Reverses: restore `remote_url_reads_the_rdf_resource_form_too`
   Q6 - Taken:    `#[non_exhaustive]` on `WellKnownNs` stands (decision 2: pay the break once before #421 adds twelve variants)
        Rejected: leaving the enum exhaustive
        Reverses: drop the attribute

Unresolved review notes

  • references/c2pa/README.md clause map: the gamut-metadata row does not yet list §11.5 / §15.5.3.1 / §15.5.3.2, and gamut-xmp (which now cites §11.5 in namespace.rs) has no row. Outside this PR's manifest (decision 13).
  • Q2 residual for the release reviewer: the #[non_exhaustive] break reaches consumers through the pub use gamut_xmp as xmp re-exports in gamut-iptc, gamut-metadata and the umbrella (decisions 10 and 15). This lane adds no BREAKING CHANGE footer in those crates; check the outcome against release-plz's semver check at release time.
  • Pre-existing rustdoc redundant_explicit_links in crates/gamut-metadata/src/embed.rs:186-197, visible only under RUSTDOCFLAGS="-D warnings", which is not a repository gate.

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
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.

gamut-xmp/gamut-metadata: report remote provenance from dcterms:provenance

1 participant