Repository navigation
Description
Part of #13. Split out of #14.
Problem
The write side of AI disclosure has a vocabulary. The read side has none.
DisclosureRecord, DigitalSourceType and Stamped say what is being written and what landed. Nothing says what a file currently carries. There is no verification result, no validation status, no trust verdict, and no type that distinguishes "this file has no mark" from "this file's mark is intact" from "this file's mark no longer matches its bytes".
The judgement itself is not missing — it exists, in a #[cfg(test)] helper in the infrastructure crate. That helper decides which C2PA validation failures mean the bytes were edited and which do not, and explains in a doc comment why signingCredential.untrusted and claimSignature.mismatch are excluded from the tampering verdict. That is domain knowledge held where no production path can reach it, and it is the exact knowledge needed to answer the question this feature keeps running into: what does a green readback mean when the certificate is self-signed?
Why the distinction matters more than it looks
Four outcomes are routinely collapsed into "valid / invalid", and they lead somewhere different:
| Outcome | What it means | What a user should do |
|---|---|---|
| No mark | The file was never stamped, or a conversion stripped it | Re-apply from the database |
| Mark present, bindings match | The disclosure describes these bytes | Nothing |
| Mark present, bindings mismatch | The bytes changed after signing | Do not trust the statement; re-derive |
| Signer not on a trust list | Says nothing about the bytes | Nothing, today — see below |
The last row is not a defect of the file. C2PA's own validation state stays Valid when signingCredential.untrusted is the only failure, and the reference implementation's Display for a failure summary filters it out entirely.
(https://spec.c2pa.org/specifications/specifications/2.3/specs/C2PA_Specification.html#_validation_states)
There is a second reason to keep the axes apart, and it is a trap. In the reference implementation, trust checking is on by default while the trust anchor set is empty outside of its own test configuration. A release build therefore reports every signer as untrusted, including a genuine conformance-program certificate, until anchors are configured. A verdict type that folded trust into validity would make a correctly signed file look broken in production and fine under cargo test.
Scope
- A domain vocabulary for what a file carries and how it stands up, with the integrity axis and the trust axis separate.
- A port for reading it, alongside the existing write-only port.
- An adapter implementing that port over the C2PA reader and the existing XMP packet reader, which already exist and are already exercised by tests.
- Registered settings for the trust configuration, so anchors and the on/off of trust checking are visible and explainable rather than implicit in a library default.
Not in scope: signing, the export path wiring, and the certificate policy — those stay in #14.
Acceptance criteria
- A domain type expresses, for one file, which of the four outcomes above holds, and the trust axis is a separate value from the integrity axis. It is not an
Option, for the reason the series key already records: "not about this material" and "about it and found nothing" are different answers. - The XMP half and the manifest half are reported independently, matching the shape the write outcome already uses — the two fail independently and a caller must be able to see which one landed.
- The mapping from validation status codes to the verdict lives in the domain, with unit tests over the codes, and no longer in a test helper. Unrecognised codes are not silently treated as success.
- The read port is declared in the core beside the write port, and an infrastructure adapter implements it. The core does not name the C2PA SDK.
- Trust checking and its anchor material are registered settings, so a deployment can turn trust evaluation on with real anchors without a code change, and the default is explainable in one sentence.
- A verdict is reachable for a file that carries nothing, without that being an error.
Step 1 landed in #273
Done — the domain half: Mark, Signer and Carried in crates/asterism-core/src/domain/disclosure/carried.rs, the status-code mapping as integrity_of with unit tests over the codes, and the DisclosureReader port beside the writer. Criteria 1, 2, 3, and the port half of 4.
Not done — the adapter, the trust configuration, and every test that reads a real file back: the rest of 4, and 6. Criterion 5 is withdrawn by the comment below, which replaces it.
What that step settled, which changes what is left
claimSignature.mismatch is not a trust verdict. Measured on this repository's own signed files and pinned as what_the_sdk_reports_for_this_builds_own_signatures:
| certificate | failures | signature issuer |
|---|---|---|
with an organizationName |
signingCredential.untrusted |
named |
| without one | signingCredential.untrusted, claimSignature.mismatch |
absent |
So the mismatch tracks the missing attribute — #179's upstream defect — rather than the trust check, and the manifest's signature issuer is the one field that separates that defect from a real forgery. integrity_of takes it, and an unnamed signer's mismatch answers Mark::Undetermined, the fifth state the comment below asked for.
The fixture question #179 raises is narrower than it looked: issued_shaped_pair already carries an organisation, so the integration tests this issue asks for can be written against it today. What #179 still owns is that self_signed_pair does not, and that the tree reasons from it in places as though its behaviour were the SDK's.
Open — to be settled while implementing
- Whether the verdict carries the parsed disclosure back (the packet's terms and the manifest's assertions) or only the judgement. Reading the packet back into a
DisclosureRecordwould let the application compare what a file says against what the database says; that comparison is the natural next feature and may or may not belong in the same type. - Whether an unreadable packet that was just written stays an error variant or becomes one of the outcomes.
- Where the verdict surfaces — this issue can land without any HTTP or IPC exposure.
Verification
- Unit tests over the status-code mapping, pure, with no file and no SDK. Done in Say what a file already carries, apart from who signed it #273.
- An integration test that signs with a throwaway certificate, reads back, and asserts the verdict is "intact, untrusted" — not "invalid". Against
issued_shaped_pair, whose certificate carries an organisation; againstself_signed_pairthe honest assertion is "undetermined, untrusted", and both are worth pinning. - An integration test that edits a signed file's packet and asserts the verdict changes on the integrity axis while the trust axis does not move.
- An integration test that a file carrying nothing yields the no-mark outcome rather than an error.
Activity
ynishi commentedon Aug 26, 2026
Two things settled while implementing, both of which change what this issue asks for. Recording them here rather than only in the branch, because one withdraws an acceptance criterion and the other adds a state to the vocabulary criterion 1 enumerates.
Criterion 5 is withdrawn
It asked for trust checking and its anchor material to be registered settings. That names a mechanism, and the mechanism collides with a rule this repository had already decided.
core_init.rsrefuses a registry key holding a path for two reasons:PUT /asterism/settings/{key}would let whoever reaches that route choose which file the process opens at the next launch, and a registry key is readable back with every layer, publishing the location.The first reason applies to a trust anchor bundle unchanged. Whoever can write
disclosure.verify.trust_anchorscan point the verifier at a bundle of their own and make any signer come back trusted — a trust root is the one input an attacker wants to substitute rather than to read, so "it is public certificate material" answers the second reason and not the first. The preferences screen renders every registry row, so the two keys would also have arrived as editable controls on a user preferences screen, which is not what an arrangement with a certificate authority is.So the keys are gone. Trust configuration is the composition root's to read, in the same shape as the signing variables, and it lands in the change that first reads a disclosure back — the same principle
asterism-ui'sbuild.rsstates for bindings: a thing enters in the change that consumes it. What this change lands is the vocabulary, the port, the adapter, and the parse-and-refuse policy; the default, trust evaluation off, is explainable in one sentence.The replacement wording:
Criterion 1 needs a fourth answer, and the reason is measured
Criterion 1 asks for a type expressing which of four outcomes holds, with the trust axis separate from the integrity axis, and argues against
Optionbecause "not about this material" and "about it and found nothing" are different answers. Implementing it turned up a case where the integrity axis has to say a third thing, for the same reason.c2pa0.90.12 emitsclaimSignature.mismatchboth for a genuine forgery and for a signing certificate whose subject carries noorganizationName. The second is an upstream defect — the organisation is fetched for display after the signature has already verified, and its absence becomes an error thatclaim.rsfolds into that one code (contentauth/c2pa-rs#2262,accepted, fix PR #2540 unmerged; the C2PA specification requires no such attribute). Measured againstc2pa =0.90.12with this workspace's own features, the two cases are identical across every field ofvalidation_results()— same code, same explanation "claim signature is not valid", same success list includingassertion.dataHash.match, samevalidation_state: Invalid. The local write-up is #179.One field separates them:
Manifest::signature_info().issuer, which isNoneexactly when the attribute is absent, and whichc2pareads out of the certificate rather than out of the verification — so it still answers for a signature that did not verify. A named signer therefore rules the upstream defect out, and an unnamed one rules nothing in. That discriminator is in the branch.What it leaves is a case with no honest answer in the vocabulary as this issue drew it: an unnamed signer's mismatch, where the file may have been forged and may be carrying the upstream defect, and nothing available can tell. Routing it to the trust axis says a signer was refused when none was — and on the default configuration, where trust evaluation is off, it reaches a caller on no axis at all, so a forged claim signature over untouched asset bytes reads as intact. Routing it to
Brokensays the bytes changed, which may be false.So the integrity axis gains a state meaning the question was asked and could not be answered, and a file in that state does not report as holding. "The bytes changed" and "I cannot tell whether the bytes changed" lead somewhere different, which is this issue's own argument one step further in.
Criterion 1's "four outcomes" becomes five, and the table in the body — no mark / mark present and bindings match / mark present and bindings mismatch / signer not on a trust list — gains a row: mark present, and whether its bindings hold cannot be established, with the reason carried as words.
What this does not change
The write side. Measured through this repository's own writer, with an
O=-carrying certificate, both halves written:ValidationState::Validwith an empty failure list on PNG, MP4 and MOV. Nothing about the manifest, the ordering of the two halves, or the hard binding is implicated, andREADME.md's statement that a self-issued certificate validates assigningCredential.untrustedwhile the validation state staysValidis true of such a certificate. It would read as false if demonstrated with the fixture this repository currently ships, which is what #179 carries.