Skip to content

Add structured accessors on Reader for actions, signing info, and ingredients #289

Closed

Description

@HamzaJavaid-gh

Summary

Every consumer of Reader that needs more than the raw JSON string ends up writing the same small walker to reach three sub-structures of the manifest store: the c2pa.actions action list, signature_info, and ingredients. I'd like to propose three thin, additive accessors on Reader that are get_actions(), get_signing_info(), and get_ingredients(), that replace this boilerplate while staying consistent with the existing get_active_manifest() / get_manifest(label) shape.

Before I open a PR, I want to align on the API shape and a handful of design decisions.

Motivation

Concrete example from a downstream project that uses c2pa-python to detect AI-generated images. Today, reading actions, signing info, and ingredients from a Reader looks like this (~30 lines):

manifest = manifest_store["manifests"][manifest_store["active_manifest"]]
sig_info = manifest.get("signature_info", {})

actions = []
assertion_labels = []
for assertion in manifest.get("assertions", []):
    label = assertion.get("label", "")
    assertion_labels.append(label)
    if label.startswith("c2pa.actions"):  # handle both v1 and v2 label variants
        for action in assertion.get("data", {}).get("actions", []):
            agent = action.get("softwareAgent", {})
            agent_name = agent.get("name") if isinstance(agent, dict) else agent
            actions.append({"action": action.get("action"), "software_agent": agent_name, ...})

ingredients = manifest.get("ingredients", [])

With the proposed accessors, the same three reads become:

sig_info = reader.get_signing_info() or {}
actions = reader.get_actions() or []
ingredients = reader.get_ingredients() or []

The walker code is duplicated across every project that consumes Reader. It's also easy to get subtly wrong — for example, missing that c2pa.actions.v2 exists alongside c2pa.actions, or forgetting that data.actions can be absent on some assertions.

Proposed public API

Three new methods on Reader, layered on top of the existing get_active_manifest() / get_manifest(label):

class Reader(ManagedResource):
    def get_actions(self, manifest_label: Optional[str] = None) -> Optional[list[dict]]: ...
    def get_signing_info(self, manifest_label: Optional[str] = None) -> Optional[dict]: ...
    def get_ingredients(self, manifest_label: Optional[str] = None) -> Optional[list[dict]]: ...

Behavior contract:

Scenario get_actions() get_signing_info() get_ingredients()
Active manifest present, sub-structure present list[dict] dict list[dict]
Active manifest present, sub-structure missing [] None []
No active manifest (unsigned asset) None None None
manifest_label is a known label Result from that manifest Result from that manifest Result from that manifest
manifest_label is an unknown string KeyError (from get_manifest) KeyError KeyError
Reader is closed C2paError (matches Reader.json()) C2paError C2paError

Design decisions I'd like feedback on

  1. Return dict / list[dict], not typed dataclasses.
    Rationale: consistency with get_active_manifest() and get_manifest(label), resilience to C2PA JSON schema drift (new fields pass through transparently), and no new public types to export from __init__.py. Consumers who want typing can wrap. Open to going the other way if the project has a direction toward typed accessors.

  2. manifest_label=None targets the active manifest; a string targets a specific manifest.
    Rationale: mirrors existing behavior split — get_active_manifest() for None, get_manifest(label) for specific. Reuses the existing exception semantics (KeyError on unknown label) rather than inventing a third convention.

  3. get_actions() handles both c2pa.actions and c2pa.actions.v2.
    Rationale: the C2PA spec permits both labels on a single manifest. Concatenating in encounter order is the least-surprising default; alternatives (v2-only-if-present, dict-keyed-by-label) either lose information or break the flat-list contract.

  4. Do not coerce digitalSourceType to C2paDigitalSourceType.
    Rationale: the enum can trail the IPTC/CAWG taxonomy, and coercing would mean either silently dropping unknown URIs or raising on them. A separate helper for AI-source-type classification would compose cleanly on top of this without leaking an enum into a passthrough accessor.

  5. Pure Python change, no c2pa-rs or c2pa_c_ffi work.
    The accessors read from the same parsed dict that get_active_manifest() already populates via Reader.json(). No new FFI symbols and no changes to the native library.

Non-goals / out of scope for this proposal

  • No new public types exported from c2pa/__init__.py.
  • No typed-schema conversion of returned dicts.
  • No changes to Reader.json() / Reader.detailed_json() / crjson() output.
  • No enum coercion (see decision 4).
  • No changes to c2pa-rs.
  • Ancillary ideas — a validation-category enum on top of validation_status, a Settings helper for the official trust list, an is_ai_digital_source_type() classifier — are deliberately kept separate. Happy to pursue any of them in follow-up issues if the maintainers see value.

Activity

  1. tmathern commented on Jul 2, 2026

    @tmathern
    Collaborator

    Can you elaborate the contract of get_ingredients? What would it return? What happens if those ingredients have provenance themselves?

    Also, what does "sub-structure present" in your behavior contract table mean? Can you update the table you made to show also the parameters you'd pass in to those functions, for completeness?

  2. HamzaJavaid-gh commented on Jul 4, 2026

    @HamzaJavaid-gh
    Author

    Thanks for the quick look. Answering in order.

    get_ingredients() contract
    Returns Optional[list[dict]]. Each dict is a pass-through of one entry in the target manifest's ingredients list, exactly as it appears in Reader.json(). No field renaming, no reshaping, no field change. Concretely, that means each dict may carry any of the fields defined on the Ingredient struct in c2pa-rs (each is optional and omitted from the JSON when unset):

    title, format, document_id, instance_id, provenance, thumbnail, hash, relationship, active_manifest, validation_status, validation_results, data, description, informational_URI, metadata, data_types, manifest_data, label, ocsp_responses.

    Behavior:

    Returns [] (not None) when the target manifest has no ingredients key, or when ingredients is an empty list.
    Returns None in the same cases where Reader.get_active_manifest() would return None (applies when manifest_label=None — in normal operation with a successfully-constructed Reader this is rare, since unsigned assets raise C2paError.ManifestNotFound during Reader construction rather than reaching the accessor).
    Raises KeyError when manifest_label is a non-None string that doesn't name any manifest in the store — same behavior as Reader.get_manifest(label) today.

    Ingredients that have provenance themselves
    This is exactly the case the manifest_label parameter is designed to cover. When an ingredient has its own signed C2PA data, the ingredient dict carries:

    • active_manifest: a label pointing at the ingredient's own manifest, which lives as a sibling entry in the same manifest_store["manifests"] map (Ingredient::active_manifest in ingredient.rs, populated when the ingredient carries its own ManifestStore).
    • validation_results / validation_status: the ingredient's own validation state.
    • manifest_data: a ResourceRef to the ingredient's embedded C2PA blob.

    I'll walk into ingredient provenance by re-invoking the accessors with the ingredient's own label:

    for ing in reader.get_ingredients() or []:
        child_label = ing.get("active_manifest")
        if child_label:
            # Ingredient has its own signed manifest, read its actions, signing info, ingredients.
            # This recurses arbitrarily deep if the media has a nested provenance chain.
            child_actions = reader.get_actions(manifest_label=child_label)
            child_signing = reader.get_signing_info(manifest_label=child_label)
            child_ingredients = reader.get_ingredients(manifest_label=child_label)
    
    
    

    No special recursive helper in the accessor, the manifest_label parameter already gives consumers everything they need, and it mirrors how Reader.get_manifest(label) is used today. All the fields consumers need for the recursive walk (active_manifest, validation_status, validation_results, manifest_data) come through the pass-through so nothing is lost.

    If you'd prefer a different shape for the recursive walk case, happy to follow up on this contract

  3. HamzaJavaid-gh commented on Jul 4, 2026

    @HamzaJavaid-gh
    Author

    Contract table - updated

    1. Per accessor, "sub-structure" means:
    • get_actions(): at least one assertion on the target manifest labeled c2pa.actions or c2pa.actions.v2.
    • get_signing_info(): the signature_info key on the target manifest.
    • get_ingredients(): the ingredients key on the target manifest.
    1. manifest_label didn't get a first-class column. Revised table:
    manifest_label Target manifest Sub-structure get_actions() get_signing_info() get_ingredients()
    None Active manifest exists Present list[dict] dict list[dict]
    None Active manifest exists Absent [] None []
    None No active manifest (unsigned asset) — None None None
    str naming a manifest Manifest resolved Present list[dict] dict list[dict]
    str naming a manifest Manifest resolved Absent [] None []
    str not naming any manifest — — KeyError (from get_manifest) KeyError KeyError
    Any Reader closed — C2paError (matches Reader.json()) C2paError C2paError

    Happy to update the issue body with the revised table inline if you'd like, otherwise I'll fold the corrections into the PR docs.

  4. tmathern commented on Jul 7, 2026

    @tmathern
    Collaborator

    Before diving deep into this, I also want to point out that there is a new API: https://github.com/contentauth/c2pa-python/blob/main/src/c2pa/c2pa.py#L2499-L2518. This one emits crJson, which has been specified in https://spec.c2pa.org/specifications/specifications/2.4/crJSON/crjson-format.html.

  5. HamzaJavaid-gh commented on Jul 7, 2026

    @HamzaJavaid-gh
    Author

    Thanks for the pointer. I missed this part.
    You're right that this covers the use case. Every piece of data the three proposed accessors were designed to reach is already in crJSON, and in a more structured form than what Reader.json() provides.

    I'm leaning towards closing this, if you agree or any shape you'd like to discuss further, feel free.

    Appreciate you taking the time to point at crJSON before we invested in that direction.

  6. tmathern commented on Jul 10, 2026

    @tmathern
    Collaborator

    Sounds good! You can close, and always reopen if more comes up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions