diff --git a/src/lib/ReportViewerComponent.test.ts b/src/lib/ReportViewerComponent.test.ts
new file mode 100644
index 0000000..5e62719
--- /dev/null
+++ b/src/lib/ReportViewerComponent.test.ts
@@ -0,0 +1,120 @@
+import { describe, it, expect } from 'vitest'
+import { render, fireEvent } from '@testing-library/svelte'
+import ReportViewer from './ReportViewer.svelte'
+import type { ConformanceReport } from './types'
+
+describe('ReportViewer Component', () => {
+ it('should render failures grouped by manifest in Validation Status Details', () => {
+ const mockReport: ConformanceReport = {
+ manifests: [
+ {
+ label: 'active_manifest_label',
+ assertions: {},
+ validationResults: {
+ success: [
+ { code: 'signingCredential.trusted' },
+ { code: 'timeStamp.trusted' },
+ { code: 'claimSignature.validated' }
+ ],
+ failure: [
+ { code: 'assertion.bmffHash.mismatch', explanation: 'BMFF hash mismatch' }
+ ]
+ },
+ signature: {
+ certificateInfo: {
+ subject: { CN: 'Active Signer' }
+ }
+ }
+ }
+ ]
+ }
+
+ const { container, getByText } = render(ReportViewer, { report: mockReport })
+
+ // Navigate to the Report tab (default is Summary)
+ fireEvent.click(getByText('Report'))
+
+ const detailsSection = container.querySelector('#validation-status')
+ expect(detailsSection).toBeTruthy()
+
+ // Should have 1 manifest group card
+ const groupCards = detailsSection?.querySelectorAll('.manifest-group-card')
+ expect(groupCards?.length).toBe(1)
+
+ // Check header of the group
+ const header = groupCards?.[0].querySelector('h4')
+ expect(header?.textContent).toContain('Active Asset')
+ expect(header?.textContent).toContain('active_manifest_label')
+ expect(header?.textContent).toContain('signed by Active Signer')
+
+ // Check status cards inside the group (1 failure + 3 successes)
+ const failureCards = groupCards?.[0].querySelectorAll('.bg-red-50\\/50') // escaped slash for selector
+ expect(failureCards?.length).toBe(1)
+ expect(failureCards?.[0].textContent).toContain('assertion.bmffHash.mismatch')
+ expect(failureCards?.[0].textContent).toContain('BMFF hash mismatch')
+
+ const successCards = groupCards?.[0].querySelectorAll('.bg-green-50\\/50')
+ expect(successCards?.length).toBe(3)
+ expect(successCards?.[0].textContent).toContain('signingCredential.trusted')
+ })
+
+ it('should render ingredient failures in their own group card', () => {
+ const mockReport: ConformanceReport = {
+ manifests: [
+ {
+ label: 'active_label',
+ assertions: {},
+ validationResults: {
+ success: [{ code: 'signingCredential.trusted' }]
+ },
+ signature: {
+ certificateInfo: {
+ subject: { CN: 'Active Signer' }
+ }
+ }
+ },
+ {
+ label: 'ingredient_label',
+ assertions: {},
+ validationResults: {
+ failure: [{ code: 'assertion.bmffHash.mismatch', explanation: 'BMFF hash mismatch' }]
+ },
+ signature: {
+ certificateInfo: {
+ subject: { CN: 'Ingredient Signer' }
+ }
+ }
+ }
+ ]
+ }
+
+ const { container, getByText } = render(ReportViewer, { report: mockReport })
+
+ // Navigate to the Report tab (default is Summary)
+ fireEvent.click(getByText('Report'))
+
+ const detailsSection = container.querySelector('#validation-status')
+ expect(detailsSection).toBeTruthy()
+
+ // Should have 2 manifest group cards (both have statuses to show)
+ const groupCards = detailsSection?.querySelectorAll('.manifest-group-card')
+ expect(groupCards?.length).toBe(2)
+
+ // First group (Active Asset)
+ const header1 = groupCards?.[0].querySelector('h4')
+ expect(header1?.textContent).toContain('Active Asset')
+ expect(header1?.textContent).toContain('active_label')
+ expect(header1?.textContent).toContain('signed by Active Signer')
+ expect(groupCards?.[0].querySelectorAll('.bg-green-50\\/50').length).toBe(1)
+ expect(groupCards?.[0].querySelectorAll('.bg-red-50\\/50').length).toBe(0)
+
+ // Second group (Ingredient 1)
+ const header2 = groupCards?.[1].querySelector('h4')
+ expect(header2?.textContent).toContain('Ingredient 1')
+ expect(header2?.textContent).toContain('ingredient_label')
+ expect(header2?.textContent).toContain('signed by Ingredient Signer')
+ expect(groupCards?.[1].querySelectorAll('.bg-green-50\\/50').length).toBe(0)
+ expect(groupCards?.[1].querySelectorAll('.bg-red-50\\/50').length).toBe(1)
+ expect(groupCards?.[1].querySelector('.bg-red-50\\/50')?.textContent).toContain('assertion.bmffHash.mismatch')
+ })
+})
diff --git a/src/lib/RubricsPanel.svelte b/src/lib/RubricsPanel.svelte
new file mode 100644
index 0000000..432953d
--- /dev/null
+++ b/src/lib/RubricsPanel.svelte
@@ -0,0 +1,561 @@
+
+
+
+
+
+
+
+ {#if index.length > 0}
+
+
+ Select all
+
+
+ Clear
+
+
+ {/if}
+
+
+ {#if indexLoading}
+
Loading rubrics…
+ {:else if indexError}
+
+ Failed to load rubric index: {indexError}
+
+ {:else if index.length === 0}
+
No rubrics available.
+ {:else}
+
+ {#each groups as group (group.category)}
+
+
+
+
+ {group.category}
+
+
+
+ {group.entries.length}
+
+
+
+
+ {/each}
+
+
+
+
+ {selected.size} of {index.length} selected
+
+
+ {#if running}
+
+
+
+
+ Evaluating…
+ {:else}
+
+
+
+
+ Evaluate selected
+ {/if}
+
+
+ {/if}
+
+
+
+ {#if runError}
+
+
Evaluation failed
+
{runError}
+
+ {/if}
+
+
+ {#if results.length > 0}
+
+
+
+
+
+
Results
+
+ {#if docResults.length > 0}
+
+ {docPassCount} of {docResults.length} pass/fail rubrics passed
+
+ {/if}
+ {#if docResults.length > 0 && signalsResults.length > 0}
+ ·
+ {/if}
+ {#if signalsResults.length > 0}
+
+ {signalsResults.length} signals rubric{signalsResults.length === 1 ? '' : 's'}
+
+ {/if}
+ {#if ranAt}
+ · evaluated {ranAt.toLocaleTimeString()}
+ {/if}
+
+
+
+
+
+
+ {#each results as r (r.rubricId)}
+ {#if isDocumentResult(r)}
+ {@const grouped = groupByOutcome(r)}
+
+
+ {#if r.overallPassed}
+
+ {:else}
+
+ {/if}
+
+
+ {r.rubricName}
+ {#if r.rubricVersion}
+ v{r.rubricVersion}
+ {/if}
+
+ {r.overallPassed ? 'Pass' : 'Fail'}
+
+
+ {r.statements.filter((s) => s.passed === true).length}/{r.statements.length} checks passed
+
+
+
+
+
+
+ {#if grouped.failed.length > 0}
+
+
Failed
+
+ {#each grouped.failed as s (s.id)}
+
+
+
+
+
+
{s.message || s.description || s.id}
+
{s.id}
+
+
+ {/each}
+
+
+ {/if}
+
+ {#if grouped.errored.length > 0}
+
+
Errored
+
+ {#each grouped.errored as s (s.id)}
+
+
+
+
+
+
{s.message || s.description || s.id}
+
{s.id}
+
+
+ {/each}
+
+
+ {/if}
+
+ {#if grouped.passed.length > 0}
+
+
+ Passed ({grouped.passed.length})
+
+
+ {#each grouped.passed as s (s.id)}
+
+
+
+
+
+
{s.message || s.description || s.id}
+
{s.id}
+
+
+ {/each}
+
+
+ {/if}
+
+ {:else if isSignalsResult(r)}
+
+
+
+
+
+
+ {r.rubricName}
+ {#if r.rubricVersion}
+ v{r.rubricVersion}
+ {/if}
+
+ Signals
+
+
+ {r.manifests.length} manifest{r.manifests.length === 1 ? '' : 's'}
+
+
+
+
+
+
+ {#each r.manifests as m, idx (idx)}
+
+
+
+ #{idx}
+ {formatAssertedBy(m.assertedBy)}
+ {#if m.mimeType}
+ {m.mimeType}
+ {/if}
+
+
+
+ allActionsIncluded: {m.allActionsIncluded}
+
+
+
+
+ {#if totalSignalCount(m) === 0 && m.ingredients.length === 0}
+ No signals detected on this manifest.
+ {/if}
+
+ {#if m.localInceptions.length > 0}
+
+
+ Inception ({m.localInceptions.length})
+
+
+ {#each m.localInceptions as sig (sig.trait)}
+
+
+
+
+
+
+ {sig.reportText}
+ {#if sig.multiple}
+ ×multiple
+ {/if}
+
+
{sig.trait}
+
+
+ {/each}
+
+
+ {/if}
+
+ {#if m.localTransformations.length > 0}
+
+
+ Transformation ({m.localTransformations.length})
+
+
+ {#each m.localTransformations as sig (sig.trait)}
+
+
+
+
+
+
+
+ {sig.reportText}
+ {#if sig.multiple}
+ ×multiple
+ {/if}
+
+
{sig.trait}
+
+
+ {/each}
+
+
+ {/if}
+
+ {#if m.ingredients.length > 0}
+
+
+ Ingredients ({m.ingredients.length})
+
+
+ {#each m.ingredients as edge, eidx (eidx)}
+
+ → manifest #{edge.index}
+ {#if edge.relationship}
+ {edge.relationship}
+ {/if}
+
+ {/each}
+
+
+ {/if}
+
+ {/each}
+
+
+ {/if}
+ {/each}
+
+
+ {/if}
+
diff --git a/src/lib/TreeNode.svelte b/src/lib/TreeNode.svelte
new file mode 100644
index 0000000..04a5651
--- /dev/null
+++ b/src/lib/TreeNode.svelte
@@ -0,0 +1,168 @@
+
+
+
+
+
onZoom && !isRoot && !node.isStub && onZoom(node.manifestIdx)}
+ >
+
+ {#if previewSrc}
+ {#if (fileSrc && fileMimeType?.startsWith('video/')) || (!fileSrc && node.mimeType?.startsWith('video/'))}
+
+ {:else}
+
+ {/if}
+ {:else}
+
+
+ {#if node.mimeType?.startsWith('video/')}
+
+
+
+
+ {:else if node.mimeType?.startsWith('audio/')}
+
+
+
+
+ {:else}
+
+
+
+
+ {/if}
+
+ {/if}
+
+
+ {#if !node.isStub}
+
+
+
+ {/if}
+
+
+ {#if !isRoot && !node.isStub}
+
+ {/if}
+
+
+
+
+
+ {#if !isRoot && node.relationship}
+
+ {formatRelationship(node.relationship)}
+
+ {/if}
+
+
+
+ {#if node.isStub}
+ {node.claimGenerator ?? 'Unknown file'}
+ {:else}
+ {isRoot ? (fileName ?? 'This File') : (node.claimGenerator ?? 'Unknown')}
+ {/if}
+
+
+
+ {#if node.isStub}
+
No Content Credentials
+ {:else}
+
+ {#if node.date}
+
{node.date}
+ {/if}
+
+
+ {#if node.inceptions.length > 0 || node.transformations.length > 0}
+
+ {#each node.inceptions as s}
+ {s}
+ {/each}
+ {#each node.transformations as s}
+ {s}
+ {/each}
+
+ {/if}
+ {/if}
+
+
+
+ {#if node.children.length > 0}
+
+
+ {#each connPaths as d, i}
+
+ {/each}
+
+
+
+
+ {#each node.children as child, i}
+
+
+
+ {/each}
+
+ {/if}
+
diff --git a/src/lib/c2pa.test.ts b/src/lib/c2pa.test.ts
index 72bb454..0d86bbf 100644
--- a/src/lib/c2pa.test.ts
+++ b/src/lib/c2pa.test.ts
@@ -1,6 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
-import type { ValidationStatus } from '@contentauth/c2pa-web'
-import { processFile, getVersion } from './c2pa'
+import { processFile, getVersion, isSidecarFile, resolveMimeType, SIDECAR_MIME } from './c2pa'
import type { ConformanceReport } from './types'
// Track which validation is being called
@@ -133,6 +132,35 @@ describe('c2pa utilities', () => {
})
})
+ describe('sidecar detection', () => {
+ // Browsers almost never set a MIME for .c2pa files, so extension-based
+ // detection is doing the real work here. We cover both shapes just in
+ // case a future environment fills in `type`.
+ it('detects a .c2pa file with no browser-reported MIME as a sidecar', () => {
+ const f = new File([new Uint8Array([0])], 'my-manifest.c2pa', { type: '' })
+ expect(isSidecarFile(f)).toBe(true)
+ expect(resolveMimeType(f)).toBe(SIDECAR_MIME)
+ })
+
+ it('detects a .c2pa file served as application/octet-stream', () => {
+ const f = new File([new Uint8Array([0])], 'my-manifest.c2pa', { type: 'application/octet-stream' })
+ expect(isSidecarFile(f)).toBe(true)
+ expect(resolveMimeType(f)).toBe(SIDECAR_MIME)
+ })
+
+ it('detects a file whose MIME is already application/c2pa', () => {
+ const f = new File([new Uint8Array([0])], 'no-extension', { type: SIDECAR_MIME })
+ expect(isSidecarFile(f)).toBe(true)
+ expect(resolveMimeType(f)).toBe(SIDECAR_MIME)
+ })
+
+ it('does NOT mis-detect a .jpg as a sidecar', () => {
+ const f = new File([new Uint8Array([0])], 'photo.jpg', { type: 'image/jpeg' })
+ expect(isSidecarFile(f)).toBe(false)
+ expect(resolveMimeType(f)).toBe('image/jpeg')
+ })
+ })
+
describe('processFile', () => {
it('should process a file and return manifest store with trusted signature', async () => {
// Reset to simulate trusted signature from the start
@@ -192,10 +220,10 @@ describe('c2pa utilities', () => {
expect(result).toBeDefined()
// Check if ITL validation succeeded
const hasUntrusted = result.validationResults?.activeManifest?.failure?.some(
- (f: ValidationStatus) => f.code === 'signingCredential.untrusted'
+ (f) => f.code === 'signingCredential.untrusted'
)
const hasTrusted = result.validationResults?.activeManifest?.success?.some(
- (s: ValidationStatus) => s.code === 'signingCredential.trusted'
+ (s) => s.code === 'signingCredential.trusted'
)
if (hasTrusted && !hasUntrusted) {
diff --git a/src/lib/c2pa.ts b/src/lib/c2pa.ts
index 5acca25..1e02ffc 100644
--- a/src/lib/c2pa.ts
+++ b/src/lib/c2pa.ts
@@ -1,5 +1,5 @@
import { createC2pa } from '@contentauth/c2pa-web'
-import type { Settings, ValidationStatus } from '@contentauth/c2pa-web'
+import type { Settings } from '@contentauth/c2pa-web'
import { VERSION_INFO } from './version'
import type { ConformanceReport } from './types'
import { VALIDATION_STATUS } from './constants'
@@ -8,11 +8,22 @@ import { isCrJson, legacyToCrJson, getActiveManifestValidationStatus, type CrJso
type ReaderHandle = {
manifestStore: () => Promise
free: () => Promise
+ resourceToBytes?: (uri: string) => Promise
}
type C2paInstance = {
reader: {
fromBlob: (format: string, file: Blob, settings?: Settings) => Promise
+ fromSidecarAndBlob?: (
+ sidecarBytes: Uint8Array,
+ assetFormat: string,
+ assetFile: Blob,
+ settings?: Settings,
+ ) => Promise
+ fromSidecarIntegrityOnly?: (
+ sidecarBytes: Uint8Array,
+ settings?: Settings,
+ ) => Promise
}
getVersion?: () => Promise | string
}
@@ -21,6 +32,16 @@ type LocalC2paModule = {
default: () => Promise
get_version: () => string
read_manifest_store: (fileBytes: Uint8Array, format: string, settingsJson?: string) => Promise
+ read_sidecar_manifest_store?: (
+ manifestBytes: Uint8Array,
+ assetBytes: Uint8Array,
+ assetFormat: string,
+ settingsJson?: string,
+ ) => Promise
+ read_sidecar_integrity_only?: (
+ manifestBytes: Uint8Array,
+ settingsJson?: string,
+ ) => Promise
}
type ExtractedCrJsonResult = {
@@ -34,6 +55,9 @@ const importModule = new Function('modulePath', 'return import(modulePath)') as
type ITL = { allowed: string; anchors: string }
let c2paInstance: C2paInstance | null = null
+let packagedC2paInstance: C2paInstance | null = null
+// Cached raw packaged-SDK promise, shared between getPackagedC2pa() and enrichThumbnailsViaPackagedSdk().
+let packagedSdkPromise: ReturnType | null = null
let mainTrustListPem: string | null = null
let itl: ITL | null = null
@@ -80,6 +104,14 @@ async function createLocalC2pa(): Promise {
const localModule = await importModule(moduleUrl)
await localModule.default()
+ const parseCrJson = (raw: string): CrJson => {
+ const parsed = JSON.parse(raw) as CrJson
+ if (!isCrJson(parsed)) {
+ throw new Error('Local WASM returned non-crJSON format')
+ }
+ return parsed
+ }
+
return {
reader: {
fromBlob: async (format: string, file: Blob, settings?: Settings) => ({
@@ -90,14 +122,49 @@ async function createLocalC2pa(): Promise {
format,
toLocalSettingsJson(settings)
)
- const parsed = JSON.parse(manifestStoreJson) as CrJson
- if (!isCrJson(parsed)) {
- throw new Error('Local WASM returned non-crJSON format')
- }
- return parsed
+ return parseCrJson(manifestStoreJson)
},
free: async () => {},
}),
+ ...(typeof localModule.read_sidecar_manifest_store === 'function'
+ ? {
+ fromSidecarAndBlob: async (
+ sidecarBytes: Uint8Array,
+ assetFormat: string,
+ assetFile: Blob,
+ settings?: Settings,
+ ) => ({
+ manifestStore: async () => {
+ const assetBytes = new Uint8Array(await assetFile.arrayBuffer())
+ const json = await localModule.read_sidecar_manifest_store!(
+ sidecarBytes,
+ assetBytes,
+ assetFormat,
+ toLocalSettingsJson(settings),
+ )
+ return parseCrJson(json)
+ },
+ free: async () => {},
+ }),
+ }
+ : {}),
+ ...(typeof localModule.read_sidecar_integrity_only === 'function'
+ ? {
+ fromSidecarIntegrityOnly: async (
+ sidecarBytes: Uint8Array,
+ settings?: Settings,
+ ) => ({
+ manifestStore: async () => {
+ const json = await localModule.read_sidecar_integrity_only!(
+ sidecarBytes,
+ toLocalSettingsJson(settings),
+ )
+ return parseCrJson(json)
+ },
+ free: async () => {},
+ }),
+ }
+ : {}),
},
getVersion: () => localModule.get_version(),
}
@@ -180,6 +247,39 @@ async function fetchITL(): Promise {
}
}
+/** Wrap a @contentauth/c2pa-web SDK instance as a C2paInstance (legacy JSON → crJSON). */
+function wrapPackagedSdk(sdk: Awaited>): C2paInstance {
+ return {
+ reader: {
+ fromBlob: async (format: string, file: Blob, settings?: Settings) => {
+ const reader = await sdk.reader.fromBlob(format, file, settings)
+ if (!reader) return null
+ return {
+ manifestStore: async () => {
+ const legacy = await reader.manifestStore() as Record
+ return legacyToCrJson(legacy)
+ },
+ free: async () => { await reader.free() },
+ ...(reader.resourceToBytes && { resourceToBytes: reader.resourceToBytes.bind(reader) }),
+ }
+ },
+ },
+ getVersion: () => '@contentauth/c2pa-web v0.6.1',
+ }
+}
+
+/**
+ * Return the packaged SDK as a C2paInstance, lazily initialised and cached.
+ * Used for sidecar files and thumbnail fallback — never replaced by the local WASM.
+ */
+async function getPackagedC2pa(): Promise {
+ if (!packagedC2paInstance) {
+ if (!packagedSdkPromise) packagedSdkPromise = createC2pa({ wasmSrc: `${base}c2pa.wasm` })
+ packagedC2paInstance = wrapPackagedSdk(await packagedSdkPromise)
+ }
+ return packagedC2paInstance
+}
+
/**
* Initialize the C2PA SDK
*/
@@ -189,37 +289,7 @@ async function initC2pa(): Promise {
}
try {
- c2paInstance = await createLocalC2pa()
-
- if (!c2paInstance) {
- const fallbackSdk = await createC2pa({
- wasmSrc: `${base}c2pa.wasm`
- })
-
- c2paInstance = {
- reader: {
- fromBlob: async (format: string, file: Blob, settings?: Settings) => {
- const reader = await fallbackSdk.reader.fromBlob(format, file, settings)
-
- if (!reader) {
- return null
- }
-
- return {
- manifestStore: async () => {
- const legacy = await reader.manifestStore() as Record
- return legacyToCrJson(legacy)
- },
- free: async () => {
- await reader.free()
- },
- }
- },
- },
- getVersion: () => '@contentauth/c2pa-web v0.6.1',
- }
- }
-
+ c2paInstance = await createLocalC2pa() ?? await getPackagedC2pa()
return c2paInstance
} catch (error) {
console.error('Failed to initialize C2PA SDK:', error)
@@ -241,7 +311,10 @@ const MIME_TYPE_MAP: Record = {
'image/dng': 'image/x-adobe-dng',
}
-// Fallback MIME types by file extension, for when the browser can't determine the type
+// Fallback MIME types by file extension, for when the browser can't determine the type.
+// `.c2pa` is the standalone manifest-store sidecar format (RFC-style, no embedded asset).
+// Browsers universally leave its type empty or fall back to application/octet-stream, so
+// we resolve by extension.
const EXTENSION_MIME_MAP: Record = {
'dng': 'image/x-adobe-dng',
'arw': 'image/x-sony-arw',
@@ -250,200 +323,332 @@ const EXTENSION_MIME_MAP: Record = {
'nef': 'image/x-nikon-nef',
'orf': 'image/x-olympus-orf',
'rw2': 'image/x-panasonic-rw2',
+ 'c2pa': 'application/c2pa',
}
-function resolveMimeType(file: File): string {
+/**
+ * The MIME type the C2PA SDK uses for standalone manifest-store sidecars.
+ * Re-exported so UI code can detect this class of file consistently.
+ */
+export const SIDECAR_MIME = 'application/c2pa'
+
+/**
+ * True when the given File looks like a C2PA sidecar (standalone manifest store).
+ * Matches either the MIME type (if the browser somehow set it) or the `.c2pa`
+ * extension — which is how we'll detect it in ~100% of real drops, since no
+ * browser recognises the type natively yet.
+ */
+export function isSidecarFile(file: File): boolean {
+ if (file.type === SIDECAR_MIME) return true
+ const ext = file.name.split('.').pop()?.toLowerCase() ?? ''
+ return ext === 'c2pa' || ext === 'json'
+}
+
+export function resolveMimeType(file: File): string {
const mapped = MIME_TYPE_MAP[file.type]
if (mapped) return mapped
if (file.type && file.type !== 'application/octet-stream') return file.type
- // Fall back to extension-based detection
+ // Fall back to extension-based detection, then to generic bytes.
+ // `application/octet-stream` lets c2pa-rs hash arbitrary asset bytes for
+ // sidecar+asset validation (c2pa.hash.data is format-agnostic).
const ext = file.name.split('.').pop()?.toLowerCase() ?? ''
- return EXTENSION_MIME_MAP[ext] ?? file.type
+ return EXTENSION_MIME_MAP[ext] ?? (file.type || 'application/octet-stream')
}
-async function extractCrJsonWithMetadata(file: File, testCertificates: string[] = []): Promise {
- const mimeType = resolveMimeType(file)
- console.log('🔍 Starting file processing for:', file.name, 'Type:', file.type, mimeType !== file.type ? `(remapped to ${mimeType})` : '')
+/**
+ * Read the manifest store under a given set of trust settings. Returning
+ * `null` means "the SDK could not construct a reader for these inputs" —
+ * typically no manifest present, or (in the sidecar+asset case) the asset
+ * bytes don't match the manifest's hash bindings.
+ */
+type ReadManifestStore = (settings: Settings) => Promise
- // Initialize C2PA SDK if not already initialized
- console.log('Initializing C2PA SDK...')
- const c2pa = await initC2pa()
- console.log('✅ C2PA SDK initialized')
+/**
+ * Three-step trust validation flow, independent of how bytes are sourced:
+ *
+ * 1. Official C2PA trust list.
+ * 2. + Session-only test certificates, if they change the outcome.
+ * 3. + ITL (Interim Trust List), as a last-resort fallback.
+ *
+ * The "how do I read the manifest store" piece is injected so this flow
+ * works identically for embedded (`fromBlob`) and sidecar+asset
+ * (`fromSidecarAndBlob`) validation.
+ */
+async function runTrustValidationFlow(
+ readManifestStore: ReadManifestStore,
+ testCertificates: string[],
+ noManifestErrorMessage: string,
+): Promise {
+ console.log('Fetching official C2PA trust lists...')
+ const [mainTrustList, itlData] = await Promise.all([
+ fetchMainTrustList(),
+ fetchITL()
+ ])
+
+ console.log('Step 1: Validating with official trust list only...')
+ const officialSettings: Settings = {
+ verify: { verifyTrust: true, verifyAfterReading: true },
+ trust: { trustAnchors: mainTrustList }
+ }
- try {
- console.log('Fetching official C2PA trust lists...')
+ const officialCrJson = await readManifestStore(officialSettings)
+ if (!officialCrJson) {
+ throw new Error(noManifestErrorMessage)
+ }
- // Fetch main trust list and ITL separately
- const [mainTrustList, itlData] = await Promise.all([
- fetchMainTrustList(),
- fetchITL()
- ])
+ console.log('📋 Raw crJSON keys:', Object.keys(officialCrJson))
+ console.log('📋 validationResults:', JSON.stringify(officialCrJson.validationResults ?? null))
+ console.log('📋 manifests[0] vr:', JSON.stringify((officialCrJson.manifests?.[0] as Record)?.validationResults ?? null))
+
+ const officialVr = getActiveManifestValidationStatus(officialCrJson)
+ const officialUntrusted = officialVr?.failure?.some(
+ (status) => status.code === VALIDATION_STATUS.SIGNING_CREDENTIAL_UNTRUSTED
+ )
+
+ console.log('Official TL validation results:', {
+ isUntrusted: officialUntrusted,
+ success: officialVr?.success?.map((s) => s.code),
+ failure: officialVr?.failure?.map((f) => f.code)
+ })
+
+ let crJson = officialCrJson
+ let usedTestCerts = false
+
+ if (testCertificates.length > 0) {
+ console.log('Step 2: Validating with test certificates added...')
+ const testSettings: Settings = {
+ verify: { verifyTrust: true, verifyAfterReading: true },
+ trust: { trustAnchors: mainTrustList + '\n' + testCertificates.join('\n') }
+ }
- console.log('Step 1: Validating with official trust list only...')
- const officialSettings: Settings = {
- verify: {
- verifyTrust: true,
- verifyAfterReading: true
- },
- trust: {
- trustAnchors: mainTrustList
+ const testCrJson = await readManifestStore(testSettings)
+ if (testCrJson) {
+ const testVr = getActiveManifestValidationStatus(testCrJson)
+ const testUntrusted = testVr?.failure?.some(
+ (status) => status.code === VALIDATION_STATUS.SIGNING_CREDENTIAL_UNTRUSTED
+ )
+
+ console.log('Test cert validation results:', {
+ isUntrusted: testUntrusted,
+ success: testVr?.success?.map((s) => s.code),
+ failure: testVr?.failure?.map((f) => f.code)
+ })
+
+ if (officialUntrusted && !testUntrusted) {
+ console.log('✅ Test certificates made the difference - signature now trusted')
+ usedTestCerts = true
+ crJson = testCrJson
+ } else {
+ console.log('ℹ️ Test certificates loaded but not needed for validation')
}
}
+ }
- // First validation with official trust list only (no test certs)
- const reader1 = await c2pa.reader.fromBlob(mimeType, file, officialSettings)
- if (!reader1) {
- throw new Error('No C2PA manifest found in this file')
- }
+ const mainVr = getActiveManifestValidationStatus(crJson)
+ const isUntrusted = mainVr?.failure?.some(
+ (status) => status.code === VALIDATION_STATUS.SIGNING_CREDENTIAL_UNTRUSTED
+ )
- const officialCrJson = await reader1.manifestStore()
- await reader1.free()
+ console.log('Main validation results:', {
+ isUntrusted,
+ success: mainVr?.success?.map((s) => s.code),
+ failure: mainVr?.failure?.map((f) => f.code)
+ })
- console.log('📋 Raw crJSON keys:', Object.keys(officialCrJson))
- console.log('📋 validationResults:', JSON.stringify(officialCrJson.validationResults ?? null))
- console.log('📋 manifests[0] vr:', JSON.stringify((officialCrJson.manifests?.[0] as Record)?.validationResults ?? null))
+ let usedITL = false
+ let finalCrJson = crJson
- const vr = getActiveManifestValidationStatus(officialCrJson)
- const officialUntrusted = vr?.failure?.some(
- (status: ValidationStatus) => status.code === VALIDATION_STATUS.SIGNING_CREDENTIAL_UNTRUSTED
- )
+ if (isUntrusted) {
+ console.log('⚠️ Signature untrusted on main list, checking ITL...')
- console.log('Official TL validation results:', {
- isUntrusted: officialUntrusted,
- success: vr?.success?.map((s: ValidationStatus) => s.code),
- failure: vr?.failure?.map((f: ValidationStatus) => f.code)
- })
-
- let crJson = officialCrJson
- let usedTestCerts = false
-
- if (testCertificates.length > 0) {
- console.log('Step 2: Validating with test certificates added...')
- const testSettings: Settings = {
- verify: {
- verifyTrust: true,
- verifyAfterReading: true
- },
- trust: {
- trustAnchors: mainTrustList + '\n' + testCertificates.join('\n')
- }
+ // allowed.pem = leaf/end-entity certs → allowedList
+ // anchors.pem = root CAs → appended to trustAnchors
+ const itlSettings: Settings = {
+ verify: { verifyTrust: true, verifyAfterReading: true },
+ trust: {
+ trustAnchors: mainTrustList + '\n' + itlData.anchors,
+ allowedList: itlData.allowed,
}
+ }
- const reader2 = await c2pa.reader.fromBlob(mimeType, file, testSettings)
- if (reader2) {
- const testCrJson = await reader2.manifestStore()
- await reader2.free()
+ const itlCrJson = await readManifestStore(itlSettings)
+ if (itlCrJson) {
+ const itlVr = getActiveManifestValidationStatus(itlCrJson)
+ console.log('ITL validation results:', {
+ success: itlVr?.success?.map((s) => s.code),
+ failure: itlVr?.failure?.map((f) => ({ code: f.code, explanation: f.explanation }))
+ })
- const testVr = getActiveManifestValidationStatus(testCrJson)
- const testUntrusted = testVr?.failure?.some(
- (status: ValidationStatus) => status.code === VALIDATION_STATUS.SIGNING_CREDENTIAL_UNTRUSTED
+ const itlTrusted = itlVr?.success?.some(
+ (status) => status.code === VALIDATION_STATUS.SIGNING_CREDENTIAL_TRUSTED
+ )
+ const itlStillUntrusted = itlVr?.failure?.some(
+ (status) => status.code === VALIDATION_STATUS.SIGNING_CREDENTIAL_UNTRUSTED
+ )
+
+ console.log('ITL validation check:', { itlTrusted, itlStillUntrusted })
+ if (itlStillUntrusted) {
+ const untrustedFailure = itlVr?.failure?.find(
+ (status) => status.code === VALIDATION_STATUS.SIGNING_CREDENTIAL_UNTRUSTED
)
+ console.log('ITL still untrusted, reason:', untrustedFailure?.explanation)
+ }
- console.log('Test cert validation results:', {
- isUntrusted: testUntrusted,
- success: testVr?.success?.map((s: ValidationStatus) => s.code),
- failure: testVr?.failure?.map((f: ValidationStatus) => f.code)
- })
-
- if (officialUntrusted && !testUntrusted) {
- console.log('✅ Test certificates made the difference - signature now trusted')
- usedTestCerts = true
- crJson = testCrJson
- } else {
- console.log('ℹ️ Test certificates loaded but not needed for validation')
- }
+ if (itlTrusted && !itlStillUntrusted) {
+ console.log('✅ Signature validated by ITL')
+ usedITL = true
+ finalCrJson = itlCrJson
+ } else {
+ console.log('❌ Signature still not trusted even with ITL')
}
}
+ }
- const mainVr = getActiveManifestValidationStatus(crJson)
- const isUntrusted = mainVr?.failure?.some(
- (status: ValidationStatus) => status.code === VALIDATION_STATUS.SIGNING_CREDENTIAL_UNTRUSTED
- )
+ console.log('✅ Manifest store retrieved with trust validation')
- console.log('Main validation results:', {
- isUntrusted,
- success: mainVr?.success?.map((s: ValidationStatus) => s.code),
- failure: mainVr?.failure?.map((f: ValidationStatus) => f.code)
- })
-
- let usedITL = false
- let finalCrJson = crJson
-
- if (isUntrusted) {
- console.log('⚠️ Signature untrusted on main list, checking ITL...')
-
- // allowed.pem = leaf/end-entity certs → allowedList
- // anchors.pem = root CAs → appended to trustAnchors
- const itlSettings: Settings = {
- verify: {
- verifyTrust: true,
- verifyAfterReading: true
- },
- trust: {
- trustAnchors: mainTrustList + '\n' + itlData.anchors,
- allowedList: itlData.allowed,
- }
+ return {
+ crJson: finalCrJson,
+ usedITL,
+ usedTestCerts,
+ }
+}
+
+/**
+ * When using the local WASM reader (which doesn't expose `resourceToBytes`),
+ * fall back to a packaged-SDK reader from the same file solely for resource resolution.
+ * The packaged SDK instance is cached so only one Web Worker is created.
+ */
+async function enrichThumbnailsViaPackagedSdk(crJson: CrJson, file: Blob, mimeType: string): Promise {
+ // Quick check: any unresolved thumbnail identifiers?
+ let hasUnresolved = false
+ outer: for (const manifest of (crJson.manifests ?? [])) {
+ const assertions = (manifest.assertions ?? {}) as Record>
+ for (const [key, assertion] of Object.entries(assertions)) {
+ if (key.startsWith('c2pa.thumbnail') && assertion && !assertion.data && typeof assertion.identifier === 'string') {
+ hasUnresolved = true
+ break outer
}
+ }
+ }
+ if (!hasUnresolved) return
- const reader2 = await c2pa.reader.fromBlob(mimeType, file, itlSettings)
- if (reader2) {
- const itlCrJson = await reader2.manifestStore()
- await reader2.free()
+ try {
+ const sdk = await getPackagedC2pa()
+ const reader = await sdk.reader.fromBlob(mimeType, file)
+ if (!reader || !reader.resourceToBytes) return
+ try {
+ await enrichThumbnails(crJson, reader.resourceToBytes.bind(reader))
+ } finally {
+ await reader.free()
+ }
+ } catch (e) {
+ console.warn('[thumbnails] Could not resolve thumbnails via packaged SDK:', e)
+ }
+}
- const itlVr = getActiveManifestValidationStatus(itlCrJson)
- console.log('ITL validation results:', {
- success: itlVr?.success?.map((s: ValidationStatus) => s.code),
- failure: itlVr?.failure?.map((f: ValidationStatus) => ({ code: f.code, explanation: f.explanation }))
- })
+/**
+ * Resolve JUMBF `identifier` URIs in thumbnail assertions to inline base64 `data` fields.
+ * Only runs when the reader exposes `resourceToBytes`; silently skips failures.
+ */
+async function enrichThumbnails(crJson: CrJson, resourceToBytes: (uri: string) => Promise): Promise {
+ for (const manifest of (crJson.manifests ?? [])) {
+ const assertions = (manifest.assertions ?? {}) as Record>
+ for (const [key, assertion] of Object.entries(assertions)) {
+ if (!key.startsWith('c2pa.thumbnail') || !assertion || typeof assertion !== 'object') continue
+ if (assertion.data) continue // already inlined
+ const identifier = assertion.identifier
+ if (typeof identifier !== 'string') continue
+ try {
+ const bytes = await resourceToBytes(identifier)
+ // Convert to base64 in chunks to avoid call-stack limits on large thumbnails
+ const chunkSize = 8192
+ let binary = ''
+ for (let i = 0; i < bytes.length; i += chunkSize) {
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize))
+ }
+ assertion.data = `b64'${btoa(binary)}'`
+ } catch {
+ // Non-fatal: skip thumbnails we can't resolve
+ }
+ }
+ }
+}
- const itlTrusted = itlVr?.success?.some(
- (status: ValidationStatus) => status.code === VALIDATION_STATUS.SIGNING_CREDENTIAL_TRUSTED
- )
- const itlStillUntrusted = itlVr?.failure?.some(
- (status: ValidationStatus) => status.code === VALIDATION_STATUS.SIGNING_CREDENTIAL_UNTRUSTED
- )
+async function extractCrJsonWithMetadata(file: File, testCertificates: string[] = []): Promise {
+ // JSON sidecars are crJSON reports — parse them directly without the SDK.
+ const ext = file.name.split('.').pop()?.toLowerCase() ?? ''
+ if (ext === 'json' || file.type === 'application/json') {
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(await file.text())
+ } catch {
+ throw new Error('This file is not valid JSON.')
+ }
+ if (!isCrJson(parsed)) {
+ throw new Error('No C2PA manifest found in this file.')
+ }
+ return { crJson: parsed, usedITL: false, usedTestCerts: false }
+ }
- console.log('ITL validation check:', { itlTrusted, itlStillUntrusted })
- if (itlStillUntrusted) {
- const untrustedFailure = itlVr?.failure?.find(
- (status: ValidationStatus) => status.code === VALIDATION_STATUS.SIGNING_CREDENTIAL_UNTRUSTED
- )
- console.log('ITL still untrusted, reason:', untrustedFailure?.explanation)
- }
+ const mimeType = resolveMimeType(file)
+ console.log('🔍 Starting file processing for:', file.name, 'Type:', file.type, mimeType !== file.type ? `(remapped to ${mimeType})` : '')
- if (itlTrusted && !itlStillUntrusted) {
- console.log('✅ Signature validated by ITL')
- usedITL = true
- finalCrJson = itlCrJson
- } else {
- console.log('❌ Signature still not trusted even with ITL')
- }
+ // Sidecar JUMBF inspected without an asset: route through the local WASM's
+ // `read_sidecar_integrity_only`, which calls c2pa-rs
+ // `with_manifest_data_and_stream_async` with an empty asset stream. This
+ // validates the JUMBF structure, signature, and certificate chain while
+ // leaving the asset-hash assertions to report `dataHash.mismatch` (expected —
+ // there is no asset to hash). The packaged SDK has no equivalent path.
+ console.log('Initializing C2PA SDK...')
+ const c2pa = await initC2pa()
+ console.log('✅ C2PA SDK initialized')
+
+ const fromSidecarIntegrityOnly = c2pa.reader.fromSidecarIntegrityOnly
+ const sidecarBytesPromise = mimeType === SIDECAR_MIME && fromSidecarIntegrityOnly
+ ? file.arrayBuffer().then(buf => new Uint8Array(buf))
+ : null
+
+ const readManifestStore: ReadManifestStore = async (settings) => {
+ const reader = sidecarBytesPromise && fromSidecarIntegrityOnly
+ ? await fromSidecarIntegrityOnly(await sidecarBytesPromise, settings)
+ : await c2pa.reader.fromBlob(mimeType, file, settings)
+ if (!reader) return null
+ try {
+ const crJson = await reader.manifestStore()
+ if (reader.resourceToBytes) {
+ await enrichThumbnails(crJson, reader.resourceToBytes.bind(reader))
+ } else {
+ await enrichThumbnailsViaPackagedSdk(crJson, file, mimeType)
}
+ return crJson
+ } finally {
+ await reader.free()
}
+ }
- console.log('✅ Manifest store retrieved with trust validation')
+ try {
+ const result = await runTrustValidationFlow(
+ readManifestStore,
+ testCertificates,
+ mimeType === SIDECAR_MIME
+ ? 'No C2PA manifest could be read from this sidecar. It may be corrupted or not a valid .c2pa file.'
+ : 'No C2PA manifest found in this file',
+ )
- return {
- crJson: finalCrJson,
- usedITL,
- usedTestCerts,
- }
+ return result
} catch (error) {
console.error('❌ Error in processFile:', error)
- if (error instanceof Error) {
- const msg = error.message
- if (msg.includes('UnsupportedFormatError') || msg.includes('Unsupported format')) {
- throw new Error(`Unsupported file format (${mimeType}). Supported formats include JPEG, PNG, WebP, AVIF, MP4, MOV, MP3, WAV, and PDF.`)
- }
- if (msg.includes('InvalidAsset') || msg.includes('Box size extends beyond') || msg.includes('box size')) {
- throw new Error(`Could not parse this file. It may be corrupted, use an unsupported codec, or the C2PA manifest may be malformed.`)
- }
- if (msg.includes('NoManifest') || msg.includes('no manifest')) {
- throw new Error(`No C2PA manifest found in this file.`)
- }
- throw new Error(`Failed to process file: ${msg}`)
+ const msg = error instanceof Error ? error.message : String(error)
+ if (msg.includes('UnsupportedFormatError') || msg.includes('Unsupported format')) {
+ throw new Error(`This file format (${mimeType}) cannot carry embedded C2PA provenance. To validate provenance for this asset, drop it together with its companion .c2pa sidecar file.`)
}
- throw error
+ if (msg.includes('InvalidAsset') || msg.includes('Box size extends beyond') || msg.includes('box size')) {
+ throw new Error(`Could not parse this file. It may be corrupted, use an unsupported codec, or the C2PA manifest may be malformed.`)
+ }
+ if (msg.includes('NoManifest') || msg.includes('no manifest') || msg.includes('No C2PA manifest') || msg.includes('no JUMBF data')) {
+ throw new Error(`No C2PA manifest found in this file.`)
+ }
+ throw new Error(`Failed to process file: ${msg}`)
}
}
@@ -452,13 +657,11 @@ export async function extractCrJson(file: File, testCertificates: string[] = [])
return crJson
}
-export async function processFile(file: File, testCertificates: string[] = []): Promise {
- const { crJson, usedITL, usedTestCerts } = await extractCrJsonWithMetadata(file, testCertificates)
-
+function buildConformanceReport(extracted: ExtractedCrJsonResult): ConformanceReport {
return {
- ...crJson,
- usedITL,
- usedTestCerts,
+ ...extracted.crJson,
+ usedITL: extracted.usedITL,
+ usedTestCerts: extracted.usedTestCerts,
_conformanceToolVersion: {
commit: VERSION_INFO.sha,
shortCommit: VERSION_INFO.shortSha,
@@ -469,6 +672,68 @@ export async function processFile(file: File, testCertificates: string[] = []):
}
}
+export async function processFile(file: File, testCertificates: string[] = []): Promise {
+ return buildConformanceReport(await extractCrJsonWithMetadata(file, testCertificates))
+}
+
+async function extractSidecarWithAssetCrJsonWithMetadata(
+ sidecar: File,
+ asset: File,
+ testCertificates: string[] = [],
+): Promise {
+ const c2pa = await initC2pa()
+ const fromSidecarAndBlob = c2pa.reader.fromSidecarAndBlob
+ if (!fromSidecarAndBlob) {
+ throw new Error('read_sidecar_manifest_store is not available in the local WASM build.')
+ }
+
+ const assetMimeType = resolveMimeType(asset)
+ const sidecarBytes = new Uint8Array(await sidecar.arrayBuffer())
+
+ const readManifestStore: ReadManifestStore = async (settings) => {
+ const reader = await fromSidecarAndBlob(sidecarBytes, assetMimeType, asset, settings)
+ if (!reader) return null
+ try {
+ const crJson = await reader.manifestStore()
+ if (reader.resourceToBytes) {
+ await enrichThumbnails(crJson, reader.resourceToBytes.bind(reader))
+ } else {
+ await enrichThumbnailsViaPackagedSdk(crJson, asset, assetMimeType)
+ }
+ return crJson
+ } finally {
+ await reader.free()
+ }
+ }
+
+ try {
+ return await runTrustValidationFlow(
+ readManifestStore,
+ testCertificates,
+ `No C2PA manifest could be read from sidecar "${sidecar.name}" paired with "${asset.name}".`,
+ )
+ } catch (error) {
+ const msg = error instanceof Error ? error.message : String(error)
+ if (msg.includes('HashMismatch') || msg.includes('dataHash') || msg.includes('bmffHash')) {
+ throw new Error(
+ `Asset hash mismatch: the sidecar's hash bindings don't match "${asset.name}". ` +
+ `The sidecar and asset are probably not a matched pair.`,
+ )
+ }
+ throw error
+ }
+}
+
+export async function processSidecarWithAsset(
+ sidecar: File,
+ asset: File,
+ testCertificates: string[] = [],
+): Promise {
+ return buildConformanceReport(
+ await extractSidecarWithAssetCrJsonWithMetadata(sidecar, asset, testCertificates),
+ )
+}
+
/**
* Get the C2PA library version
*/
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 50d138e..7ab9588 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -58,3 +58,22 @@ export const PEM_MARKERS = {
TRUSTED_CERT_BEGIN: '-----BEGIN TRUSTED CERTIFICATE-----',
TRUSTED_CERT_END: '-----END TRUSTED CERTIFICATE-----'
} as const
+
+// Validation Failure Descriptions (used when these codes appear in the failure list)
+export const VALIDATION_FAILURE_DESCRIPTIONS: Record = {
+ [VALIDATION_STATUS.SIGNING_CREDENTIAL_UNTRUSTED]: 'The signing certificate is not trusted by the configured trust store.',
+ [VALIDATION_STATUS.SIGNING_CREDENTIAL_EXPIRED]: 'The signing certificate has expired.',
+ [VALIDATION_STATUS.SIGNING_CREDENTIAL_OCSP_REVOKED]: 'The signing certificate has been revoked.',
+ [VALIDATION_STATUS.TIMESTAMP_UNTRUSTED]: 'The timestamp signature is untrusted.',
+ [VALIDATION_STATUS.CLAIM_SIGNATURE_INVALID]: 'The claim signature is invalid (the manifest may have been tampered with).',
+ [VALIDATION_STATUS.ASSERTION_HASHED_URI_MATCH]: 'An assertion hash did not match (possible tampering of assertion data).',
+ [VALIDATION_STATUS.ASSERTION_DATA_HASH_MATCH]: 'An assertion data hash did not match.',
+ 'assertion.hashedURI.mismatch': 'An assertion hash did not match (possible tampering of assertion data).',
+ 'assertion.dataHash.mismatch': 'An assertion data hash did not match.',
+ 'assertion.bmffHash.mismatch': 'BMFF hash mismatch. The media content may have been tampered with.',
+ 'manifest.multipleActive': 'Multiple active manifests found.',
+ 'manifest.update.invalid': 'Invalid manifest update.',
+ 'algorithm.unsupported': 'Unsupported cryptographic algorithm.',
+ 'general.error': 'An unexpected validation error occurred.'
+} as const
+
diff --git a/src/lib/crjson.test.ts b/src/lib/crjson.test.ts
new file mode 100644
index 0000000..8a5d80a
--- /dev/null
+++ b/src/lib/crjson.test.ts
@@ -0,0 +1,112 @@
+import { describe, it, expect } from 'vitest'
+import { getAllValidationFailures, type CrJson } from './crjson'
+
+describe('crjson utilities', () => {
+ describe('getAllValidationFailures', () => {
+ it('should return empty array if no failures', () => {
+ const report: CrJson = {
+ manifests: [
+ {
+ label: 'active',
+ assertions: {},
+ validationResults: {
+ success: [{ code: 'signingCredential.trusted' }]
+ }
+ }
+ ]
+ }
+ expect(getAllValidationFailures(report)).toEqual([])
+ })
+
+ it('should collect document-level failures', () => {
+ const report: CrJson = {
+ manifests: [{ label: 'active', assertions: {} }],
+ validationResults: {
+ failure: [{ code: 'general.error', explanation: 'error' }]
+ }
+ }
+ expect(getAllValidationFailures(report)).toEqual([
+ { code: 'general.error', explanation: 'error' }
+ ])
+ })
+
+ it('should collect activeManifest failures from document-level validationResults', () => {
+ const report: CrJson = {
+ manifests: [{ label: 'active', assertions: {} }],
+ validationResults: {
+ activeManifest: {
+ failure: [{ code: 'signingCredential.untrusted' }]
+ }
+ }
+ }
+ expect(getAllValidationFailures(report)).toEqual([
+ { code: 'signingCredential.untrusted' }
+ ])
+ })
+
+ it('should collect failures from active manifest per-manifest validationResults', () => {
+ const report: CrJson = {
+ manifests: [
+ {
+ label: 'active',
+ assertions: {},
+ validationResults: {
+ failure: [{ code: 'signingCredential.untrusted' }]
+ }
+ }
+ ]
+ }
+ expect(getAllValidationFailures(report)).toEqual([
+ { code: 'signingCredential.untrusted' }
+ ])
+ })
+
+ it('should collect failures from ingredient manifests', () => {
+ const report: CrJson = {
+ manifests: [
+ {
+ label: 'active',
+ assertions: {},
+ validationResults: {
+ success: [{ code: 'signingCredential.trusted' }]
+ }
+ },
+ {
+ label: 'ingredient',
+ assertions: {},
+ validationResults: {
+ failure: [{ code: 'claimSignature.invalid', explanation: 'bad sig' }]
+ }
+ }
+ ]
+ }
+ expect(getAllValidationFailures(report)).toEqual([
+ { code: 'claimSignature.invalid', explanation: 'bad sig' }
+ ])
+ })
+
+ it('should de-duplicate failures by code', () => {
+ const report: CrJson = {
+ manifests: [
+ {
+ label: 'active',
+ assertions: {},
+ validationResults: {
+ failure: [{ code: 'signingCredential.untrusted', explanation: '1' }]
+ }
+ },
+ {
+ label: 'ingredient',
+ assertions: {},
+ validationResults: {
+ failure: [{ code: 'signingCredential.untrusted', explanation: '2' }]
+ }
+ }
+ ]
+ }
+ expect(getAllValidationFailures(report)).toEqual([
+ { code: 'signingCredential.untrusted', explanation: '1' }
+ ])
+ })
+ })
+})
diff --git a/src/lib/crjson.ts b/src/lib/crjson.ts
index c9935ff..9b7a9f9 100644
--- a/src/lib/crjson.ts
+++ b/src/lib/crjson.ts
@@ -82,7 +82,7 @@ export interface CrJsonClaimInfo {
/** Detect if parsed JSON is crJSON format */
export function isCrJson(obj: unknown): obj is CrJson {
const o = obj as Record
- return Array.isArray(o?.manifests) && o.manifests.length > 0 && o['@context'] != null
+ return Array.isArray(o?.manifests) && o.manifests.length > 0
}
/** Read assertions as list from crJSON manifest.assertions (object → array of { label, data }) */
@@ -199,6 +199,88 @@ export function getActiveManifestValidationStatus(report: CrJson): CrJsonActiveM
return docLevel ?? (perManifest ? { success: perManifest.success, informational: perManifest.informational, failure: perManifest.failure } : undefined)
}
+/**
+ * Get all validation failures from the report, including document-level,
+ * active manifest, and all ingredient manifests.
+ */
+export function getAllValidationFailures(report: CrJson): CrJsonValidationStatus[] {
+ const failures: CrJsonValidationStatus[] = []
+
+ // 1. Document-level failures
+ if (report.validationResults?.failure) {
+ failures.push(...report.validationResults.failure)
+ }
+ if (report.validationResults?.activeManifest?.failure) {
+ failures.push(...report.validationResults.activeManifest.failure)
+ }
+
+ // 2. Per-manifest failures (active and ingredients)
+ if (report.manifests) {
+ for (const manifest of report.manifests) {
+ const perManifest = manifest.validationResults as CrJsonValidationResults | undefined
+ if (perManifest?.failure) {
+ failures.push(...perManifest.failure)
+ }
+ }
+ }
+
+ // De-duplicate by code
+ const uniqueFailures: CrJsonValidationStatus[] = []
+ const seenCodes = new Set()
+ for (const f of failures) {
+ if (!seenCodes.has(f.code)) {
+ seenCodes.add(f.code)
+ uniqueFailures.push(f)
+ }
+ }
+
+ return uniqueFailures
+}
+
+/**
+ * Get validation status for a specific manifest from crJSON.
+ * - Supports per-manifest results (native crJSON) on `m.validationResults`.
+ * - Fallback to document-level results (legacy) for the active manifest (isFirst = true).
+ */
+export function getManifestValidationStatus(
+ report: CrJson,
+ m: CrJsonManifestEntry,
+ isFirst: boolean
+): CrJsonActiveManifestStatus | undefined {
+ // 1. Try per-manifest status (c2pa-rs style crJSON)
+ const perManifest = m.validationResults as CrJsonValidationResults | undefined
+ if (perManifest && (perManifest.success?.length ?? 0) + (perManifest.failure?.length ?? 0) + (perManifest.informational?.length ?? 0) > 0) {
+ return {
+ success: perManifest.success,
+ informational: perManifest.informational,
+ failure: perManifest.failure
+ }
+ }
+
+ // 2. Fallback to document-level for active manifest (legacy)
+ if (isFirst) {
+ const docLevel = report.validationResults?.activeManifest
+ if (docLevel && (docLevel.success?.length ?? 0) + (docLevel.failure?.length ?? 0) + (docLevel.informational?.length ?? 0) > 0) {
+ return docLevel
+ }
+ // If legacy has it flat at root
+ if (report.validationResults) {
+ const vr = report.validationResults
+ if ((vr.success?.length ?? 0) + (vr.failure?.length ?? 0) + (vr.informational?.length ?? 0) > 0) {
+ return {
+ success: vr.success,
+ informational: vr.informational,
+ failure: vr.failure
+ }
+ }
+ }
+ }
+
+ return undefined
+}
+
+
+
/**
* Convert legacy ManifestStore (from Reader.json() / packaged SDK) to crJSON.
* Use only when receiving legacy format; native path is already crJSON.
diff --git a/src/lib/generateSummary.test.ts b/src/lib/generateSummary.test.ts
new file mode 100644
index 0000000..e604122
--- /dev/null
+++ b/src/lib/generateSummary.test.ts
@@ -0,0 +1,309 @@
+/**
+ * Tests for the rubric-driven manifest summary generator.
+ *
+ * The summary's _detection_ layer comes from the signals rubric — these tests
+ * feed `generateManifestSummary` synthesised `ManifestSignalsResult` values
+ * (the same shape the rubric evaluator returns) and assert the resulting
+ * sentence + details. Real-world parity is covered separately by the
+ * goldens against `__fixtures__/*.signals.json`.
+ */
+import { describe, expect, it } from 'vitest'
+import { generateManifestSummary } from './generateSummary'
+import type { CrJsonManifestEntry } from './crjson'
+import type { ManifestSignalsResult, SignalHit } from './rubrics/types'
+
+// ── Fixture builders ──────────────────────────────────────────────────
+
+function manifest(overrides: Partial = {}): CrJsonManifestEntry {
+ return {
+ label: 'urn:c2pa:test',
+ assertions: {},
+ ...overrides,
+ }
+}
+
+function withCert(m: CrJsonManifestEntry, common_name: string, issuer = 'C2PA Test CA'): CrJsonManifestEntry {
+ // c2pa-rs crJSON shape: signature.certificateInfo.{subject,issuer} are DN
+ // objects keyed by CN/O/OU/etc. The crjson.ts getSignatureInfo helper
+ // pulls common_name from `subject.CN` (not from a top-level field).
+ return {
+ ...m,
+ signature: {
+ alg: 'es256',
+ certificateInfo: {
+ subject: { CN: common_name },
+ issuer: { CN: issuer },
+ },
+ },
+ }
+}
+
+function withClaimGenerator(m: CrJsonManifestEntry, name: string): CrJsonManifestEntry {
+ return { ...m, claim: { claim_generator_info: [{ name }] } }
+}
+
+function withCamera(m: CrJsonManifestEntry, make: string, model: string): CrJsonManifestEntry {
+ return {
+ ...m,
+ assertions: {
+ ...m.assertions,
+ 'stds.exif': { make, model },
+ },
+ }
+}
+
+function withSoftware(m: CrJsonManifestEntry, agent: string): CrJsonManifestEntry {
+ return {
+ ...m,
+ assertions: {
+ ...m.assertions,
+ 'c2pa.actions': {
+ actions: [{ action: 'c2pa.edited', softwareAgent: agent }],
+ },
+ },
+ }
+}
+
+function signals(traits: { inceptions?: string[]; transformations?: string[] } = {}): ManifestSignalsResult {
+ const hit = (id: string): SignalHit => ({ trait: id, reportText: id, multiple: false })
+ return {
+ assertedBy: { CN: 'Test', O: 'Test' },
+ mimeType: null,
+ localInceptions: (traits.inceptions ?? []).map(hit),
+ localTransformations: (traits.transformations ?? []).map(hit),
+ allActionsIncluded: false,
+ ingredients: [],
+ }
+}
+
+// ── Origin-phrase tests ───────────────────────────────────────────────
+
+describe('generateManifestSummary · origin phrases (signals-driven)', () => {
+ it('captured media + camera info → "photo taken with a {camera}"', () => {
+ const m = withCamera(withCert(manifest(), 'Pixel Camera'), 'Google', 'Pixel 8')
+ const s = signals({ inceptions: ['inception:signal_capturedMedia'] })
+ const r = generateManifestSummary(m, s, [], 'image/jpeg', false, true)
+ expect(r.sentence).toBe('This is a photo taken with a Google Pixel 8.')
+ })
+
+ it('captured media without exif → "captured {media}"', () => {
+ const m = withCert(manifest(), 'Some Camera')
+ const s = signals({ inceptions: ['inception:signal_capturedMedia'] })
+ const r = generateManifestSummary(m, s, [], 'image/jpeg', false, true)
+ expect(r.sentence).toBe('This is a captured image.')
+ })
+
+ it('stitched capture + camera → "stitched photo taken with a {camera}"', () => {
+ const m = withCamera(manifest(), 'Sony', 'A7')
+ const s = signals({ inceptions: ['inception:signal_capturedMediaStitched'] })
+ const r = generateManifestSummary(m, s, [], 'image/jpeg', false, true)
+ expect(r.sentence).toBe('This is a stitched photo taken with a Sony A7.')
+ })
+
+ it('fully GenAI + creator → "{media} generated by {creator}"', () => {
+ const m = withCert(manifest(), 'Adobe Firefly')
+ const s = signals({ inceptions: ['inception:signal_fullyGenAIMedia'] })
+ const r = generateManifestSummary(m, s, [], 'image/jpeg', false, true)
+ expect(r.sentence).toBe('This is an image generated by Adobe Firefly.')
+ })
+
+ it('fully GenAI without creator → "AI-generated {media}"', () => {
+ const s = signals({ inceptions: ['inception:signal_fullyGenAIMedia'] })
+ const r = generateManifestSummary(manifest(), s, [], 'image/jpeg', false, true)
+ expect(r.sentence).toBe('This is an AI-generated image.')
+ })
+
+ it('non-GenAI digital creation + creator → "digital artwork created with {creator}"', () => {
+ const m = withSoftware(manifest(), 'Procreate')
+ const s = signals({ inceptions: ['inception:signal_nonGenAIDigitalCreation'] })
+ const r = generateManifestSummary(m, s, [], 'image/png', false, true)
+ expect(r.sentence).toBe('This is a digital artwork created with Procreate.')
+ })
+
+ it('blank canvas → digital artwork branch', () => {
+ const m = withSoftware(manifest(), 'Photoshop')
+ const s = signals({ inceptions: ['inception:signal_blankCanvas'] })
+ const r = generateManifestSummary(m, s, [], 'image/png', false, true)
+ expect(r.sentence).toBe('This is a digital artwork created with Photoshop.')
+ })
+
+ it('screen capture → "screen capture"', () => {
+ const s = signals({ inceptions: ['inception:signal_screenCaptureMayContainGenAI'] })
+ const r = generateManifestSummary(manifest(), s, [], 'image/png', false, true)
+ expect(r.sentence).toBe('This is a screen capture.')
+ })
+
+ it('partly GenAI → "{media} composed by {creator}"', () => {
+ const m = withCert(manifest(), 'Some Editor')
+ const s = signals({ inceptions: ['inception:signal_partlyGenAICreation'] })
+ const r = generateManifestSummary(m, s, [], 'image/jpeg', false, true)
+ expect(r.sentence).toBe('This is an image composed by Some Editor.')
+ })
+
+ it('no signals fired + creator → generic "{media} from {creator}" fallback', () => {
+ const m = withCert(manifest(), 'Pixel Camera')
+ const s = signals()
+ const r = generateManifestSummary(m, s, [], 'image/jpeg', false, true)
+ expect(r.sentence).toBe('This is an image from Pixel Camera.')
+ })
+
+ it('no signals + no creator → bare "{media}" fallback', () => {
+ const s = signals()
+ const r = generateManifestSummary(manifest(), s, [], 'image/jpeg', false, true)
+ expect(r.sentence).toBe('This is an image.')
+ })
+
+ it('signals=null behaves the same as no-signals fallback', () => {
+ const m = withCert(manifest(), 'Pixel Camera')
+ const r = generateManifestSummary(m, null, [], 'image/jpeg', false, true)
+ expect(r.sentence).toBe('This is an image from Pixel Camera.')
+ })
+})
+
+// ── Modification-phrase tests ─────────────────────────────────────────
+
+describe('generateManifestSummary · modification phrases', () => {
+ it('editorial AI overrides editorial non-AI', () => {
+ const m = withSoftware(manifest(), 'Photoshop')
+ const s = signals({
+ inceptions: ['inception:signal_capturedMedia'],
+ transformations: [
+ 'transformation:signal_editorialAI',
+ 'transformation:signal_editorialNonAI',
+ ],
+ })
+ const r = generateManifestSummary(m, s, [], 'image/jpeg', false, true)
+ expect(r.sentence).toContain('modified using generative AI')
+ expect(r.sentence).not.toContain('edited in')
+ })
+
+ it('editorial possibly-GenAI also surfaces as "modified using generative AI"', () => {
+ const s = signals({
+ inceptions: ['inception:signal_capturedMedia'],
+ transformations: ['transformation:signal_editorialPossiblyGenAI'],
+ })
+ const r = generateManifestSummary(manifest(), s, [], 'image/jpeg', false, true)
+ expect(r.sentence).toContain('modified using generative AI')
+ })
+
+ it('editorial non-AI + tools → "edited in {tool1} and {tool2}"', () => {
+ const m = manifest({
+ assertions: {
+ 'c2pa.actions': {
+ actions: [
+ { action: 'c2pa.edited', softwareAgent: 'Photoshop' },
+ { action: 'c2pa.filtered', softwareAgent: 'Lightroom' },
+ ],
+ },
+ },
+ })
+ const s = signals({
+ inceptions: ['inception:signal_capturedMedia'],
+ transformations: ['transformation:signal_editorialNonAI'],
+ })
+ const r = generateManifestSummary(m, s, [], 'image/jpeg', false, true)
+ expect(r.sentence).toContain('edited in Photoshop and Lightroom')
+ })
+
+ it('editorial non-AI without tool names → bare "edited"', () => {
+ const s = signals({
+ inceptions: ['inception:signal_capturedMedia'],
+ transformations: ['transformation:signal_editorialNonAI'],
+ })
+ const r = generateManifestSummary(manifest(), s, [], 'image/jpeg', false, true)
+ expect(r.sentence).toContain('edited')
+ expect(r.sentence).not.toContain('edited in')
+ })
+
+ it('non-editorial signal → "converted to a different format"', () => {
+ const s = signals({
+ inceptions: ['inception:signal_capturedMedia'],
+ transformations: ['transformation:signal_nonEditorial'],
+ })
+ const r = generateManifestSummary(manifest(), s, [], 'image/jpeg', false, true)
+ expect(r.sentence).toContain('converted to a different format')
+ })
+})
+
+// ── Untrusted branch ──────────────────────────────────────────────────
+
+describe('generateManifestSummary · untrusted', () => {
+ it('untrusted asset uses claim-generator + signed-by, ignoring signals', () => {
+ const m = withClaimGenerator(withCert(manifest(), 'Some CN'), 'My App')
+ // Even with strong signals, untrusted branch wins.
+ const s = signals({ inceptions: ['inception:signal_fullyGenAIMedia'] })
+ const r = generateManifestSummary(m, s, [], 'image/jpeg', false, false)
+ expect(r.sentence).toBe('This is an image from My App signed by Some CN.')
+ })
+})
+
+// ── Footnote details ──────────────────────────────────────────────────
+
+describe('generateManifestSummary · details', () => {
+ it('lists certificate issuer and ingredient count', () => {
+ const m = withCert(manifest(), 'Pixel Camera', 'C2PA CA Inc')
+ const s = signals({ inceptions: ['inception:signal_capturedMedia'] })
+ const r = generateManifestSummary(
+ m,
+ s,
+ [{ title: 'src.jpg', document_id: 'a', instance_id: 'b' }],
+ 'image/jpeg',
+ false,
+ true,
+ )
+ expect(r.details).toContain('Certificate issued by C2PA CA Inc')
+ expect(r.details).toContain('Based on 1 source asset')
+ })
+
+ it('pluralises source asset count > 1', () => {
+ const ingredients = [
+ { title: 'a.jpg', document_id: '1', instance_id: 'a' },
+ { title: 'b.jpg', document_id: '2', instance_id: 'b' },
+ ]
+ const r = generateManifestSummary(manifest(), signals(), ingredients, 'image/jpeg', false, true)
+ expect(r.details).toContain('Based on 2 source assets')
+ })
+})
+
+// ── Grammar / composition ─────────────────────────────────────────────
+
+describe('generateManifestSummary · grammar', () => {
+ it('uses "an" before image and "a" before video/audio', () => {
+ const r1 = generateManifestSummary(manifest(), null, [], 'image/jpeg', false, true)
+ expect(r1.sentence).toBe('This is an image.')
+ const r2 = generateManifestSummary(manifest(), null, [], 'video/mp4', false, true)
+ expect(r2.sentence).toBe('This is a video.')
+ const r3 = generateManifestSummary(manifest(), null, [], 'audio/mpeg', false, true)
+ expect(r3.sentence).toBe('This is an audio file.')
+ })
+
+ it('joins origin + one modification with a comma', () => {
+ const m = withCamera(manifest(), 'Pixel', '')
+ const s = signals({
+ inceptions: ['inception:signal_capturedMedia'],
+ transformations: ['transformation:signal_editorialAI'],
+ })
+ const r = generateManifestSummary(m, s, [], 'image/jpeg', false, true)
+ expect(r.sentence).toBe('This is a photo taken with a Pixel camera, modified using generative AI.')
+ })
+
+ it('joins two modifications with " and "', () => {
+ const m = withSoftware(manifest(), 'Photoshop')
+ const s = signals({
+ inceptions: ['inception:signal_capturedMedia'],
+ transformations: [
+ 'transformation:signal_editorialNonAI',
+ 'transformation:signal_nonEditorial',
+ ],
+ })
+ const r = generateManifestSummary(m, s, [], 'image/jpeg', false, true)
+ expect(r.sentence).toBe(
+ 'This is a captured image, edited in Photoshop and converted to a different format.',
+ )
+ })
+
+ it('returns empty summary when manifest is null', () => {
+ const r = generateManifestSummary(null, null, [], 'image/jpeg', false, true)
+ expect(r).toEqual({ sentence: '', details: [] })
+ })
+})
diff --git a/src/lib/generateSummary.ts b/src/lib/generateSummary.ts
index ae287b6..568d40f 100644
--- a/src/lib/generateSummary.ts
+++ b/src/lib/generateSummary.ts
@@ -1,302 +1,334 @@
/**
- * Generates a human-readable summary of a C2PA manifest.
- * e.g. "This photo was taken with a Sony camera and edited in Adobe Photoshop."
- * Reads directly from crJSON manifest entry via getters.
+ * Build a one-line, human-readable summary of a C2PA manifest:
+ * "This is an image taken with a Pixel Camera, edited in Adobe Photoshop."
+ *
+ * The detection layer (what kind of asset is this? was it AI-generated? was
+ * it edited?) is sourced from the **signals rubric**
+ * (`public/rubrics/asset-rubric-signals-local.yml`) so the truth source is
+ * the same YAML the Conformance TF maintains. This file only handles the
+ * narrative composition: picking templates, joining clauses, getting
+ * grammar right, and pulling a few precision fields (camera make/model,
+ * editing-software names) that the rubric doesn't return.
+ *
+ * To regenerate the input `signals: ManifestSignalsResult`, run the signals
+ * rubric via `evaluatePerManifest()` (see `lib/rubrics/perManifest.ts`).
*/
import type { CrJsonManifestEntry, CrJsonIngredientItem } from './types'
-import { getAssertionDataByLabel, getClaimInfo, getSignatureInfo, getAssertionsList } from './crjson'
-
-// IPTC digital source types
-const SOURCE_TYPE = {
- TRAINED_ALGORITHMIC: 'trainedAlgorithmicMedia',
- COMPOSITE_AI: 'compositeWithTrainedAlgorithmicMedia',
- DIGITAL_CAPTURE: 'digitalCapture',
- DIGITAL_ART: 'digitalArt',
- SCREEN_CAPTURE: 'screenCapture',
- COMPUTATIONAL: 'computationalMedia',
- COMPOSITE: 'compositeSynthetic',
- MINOR_HUMAN_EDITS: 'minorHumanEdits',
- ALGORITHMIC_MEDIA: 'algorithmicMedia',
+import {
+ getAssertionDataByLabel,
+ getAssertionsList,
+ getClaimInfo,
+ getSignatureInfo,
+} from './crjson'
+import type { ManifestSignalsResult } from './rubrics/types'
+
+// Signal ids exposed by `asset-rubric-signals-local.yml`. Centralised here
+// so a typo or upstream rename surfaces as a single point of change.
+const INCEPTION = {
+ BLANK_CANVAS: 'inception:signal_blankCanvas',
+ CAPTURED_MEDIA: 'inception:signal_capturedMedia',
+ CAPTURED_MEDIA_STITCHED: 'inception:signal_capturedMediaStitched',
+ COMPOSITION_MAY_GENAI: 'inception:signal_compositionMayContainGenAI',
+ FULLY_GENAI: 'inception:signal_fullyGenAIMedia',
+ UNKNOWN_PROVENANCE: 'inception:signal_mediaUnknownProvenance',
+ NON_GENAI_DIGITAL_CREATION: 'inception:signal_nonGenAIDigitalCreation',
+ PARTLY_GENAI: 'inception:signal_partlyGenAICreation',
+ SCREEN_CAPTURE_MAY_GENAI: 'inception:signal_screenCaptureMayContainGenAI',
} as const
-// C2PA action types
-const ACTION = {
- CONVERTED: 'c2pa.converted',
- EDITED: 'c2pa.edited',
- FILTERED: 'c2pa.filtered',
- COLOR_ADJUSTED: 'c2pa.color_adjustments',
- RESIZED: 'c2pa.resized',
- OPENED: 'c2pa.opened',
- PLACED: 'c2pa.placed',
- CREATED: 'c2pa.created',
- PUBLISHED: 'c2pa.published',
- TRANSCODED: 'c2pa.transcoded',
- AI_GENERATED: 'c2pa.ai.generatedContent',
- DREW: 'c2pa.drew',
- CROPPED: 'c2pa.cropped',
- REPACKAGED: 'c2pa.repackaged',
+const TRANSFORMATION = {
+ EDITORIAL_AI: 'transformation:signal_editorialAI',
+ EDITORIAL_NON_AI: 'transformation:signal_editorialNonAI',
+ EDITORIAL_POSSIBLY_GENAI: 'transformation:signal_editorialPossiblyGenAI',
+ NON_EDITORIAL: 'transformation:signal_nonEditorial',
} as const
-function cleanName(name: string): string {
- if (!name) return ''
- // Strip version suffixes like "AppName/1.0" or "AppName 1.0.0"
- return name.split('/')[0].trim()
+export interface ManifestSummary {
+ /** The composed sentence, capitalised and terminated with a period. */
+ sentence: string
+ /** Footnote-style facts (e.g. "Certificate issued by …"). */
+ details: string[]
}
-function getSourceType(url: string | undefined): string {
- if (!url) return ''
- const parts = url.split('/')
- return parts[parts.length - 1] || ''
-}
+/**
+ * Compose the summary. Pure function; all inputs explicit.
+ *
+ * `signals` carries the per-manifest result for THIS manifest from the
+ * signals rubric. When the caller can't load the rubric (e.g. offline /
+ * dev fallback), pass `null` and we'll do a minimal "{a/an} {media} from
+ * {signer}" fallback that never claims a property the rubric would.
+ */
+export function generateManifestSummary(
+ manifest: CrJsonManifestEntry | null | undefined,
+ signals: ManifestSignalsResult | null,
+ ingredients: CrJsonIngredientItem[],
+ mimeType: string,
+ usedITL: boolean = false,
+ isTrusted: boolean = true,
+): ManifestSummary {
+ if (!manifest) return { sentence: '', details: [] }
-function getMediaWord(mimeType: string): string {
- if (mimeType.startsWith('image/')) return 'image'
- if (mimeType.startsWith('video/')) return 'video'
- if (mimeType.startsWith('audio/')) return 'audio file'
- if (mimeType === 'application/pdf') return 'document'
- return 'file'
-}
+ const mediaWord = getMediaWord(mimeType)
+ const article = articleFor(mediaWord)
+ const signerName = getSignerName(manifest, usedITL)
-interface AssertionData {
- actions?: Array<{
- action?: string
- digitalSourceType?: string
- softwareAgent?: string | { name?: string }
- description?: string
- }>
- digitalSourceType?: string
- make?: string
- model?: string
- softwareAgent?: string | { name?: string }
-}
+ const parts: string[] = []
+ const details: string[] = []
-function getAssertionData(manifest: CrJsonManifestEntry, label: string): AssertionData | null {
- const data = getAssertionDataByLabel(manifest, label)
- return (data as AssertionData) ?? null
+ if (!isTrusted) {
+ parts.push(buildUntrustedOrigin(manifest, mediaWord, article))
+ } else {
+ parts.push(buildOriginPhrase(manifest, signals, mediaWord, article, signerName))
+ const mods = buildModificationPhrases(manifest, signals)
+ if (mods.length > 0) parts.push(joinAnd(mods))
+ }
+
+ // Footnote: signing certificate issuer.
+ const issuer = getSignatureInfo(manifest)?.issuer
+ if (issuer) details.push(`Certificate issued by ${issuer}`)
+
+ // Footnote: ingredient count.
+ const n = ingredients?.length ?? 0
+ if (n > 0) details.push(`Based on ${n} source asset${n > 1 ? 's' : ''}`)
+
+ return { sentence: composeSentence(parts), details }
}
-function getAllActions(manifest: CrJsonManifestEntry): Array<{ action: string; digitalSourceType?: string; softwareAgent?: string }> {
- const actionsAssertion = getAssertionData(manifest, 'c2pa.actions')
- if (!actionsAssertion?.actions) return []
- return actionsAssertion.actions.map(a => ({
- action: a.action ?? '',
- digitalSourceType: a.digitalSourceType,
- softwareAgent: typeof a.softwareAgent === 'string'
- ? a.softwareAgent
- : a.softwareAgent?.name,
- }))
+// ── Origin phrase ─────────────────────────────────────────────────────
+
+function buildUntrustedOrigin(
+ manifest: CrJsonManifestEntry,
+ mediaWord: string,
+ article: string,
+): string {
+ const claimGenerator = getClaimGeneratorName(manifest)
+ const commonName = getCommonName(manifest)
+ let phrase = `This is ${article} ${mediaWord}`
+ if (claimGenerator) phrase += ` from ${claimGenerator}`
+ if (commonName) phrase += ` signed by ${commonName}`
+ return phrase
}
-function getPrimaryDigitalSourceType(manifest: CrJsonManifestEntry): string {
- // Check top-level assertion first
- const actionsData = getAssertionData(manifest, 'c2pa.actions')
- if (actionsData?.digitalSourceType) {
- return getSourceType(actionsData.digitalSourceType)
+function buildOriginPhrase(
+ manifest: CrJsonManifestEntry,
+ signals: ManifestSignalsResult | null,
+ mediaWord: string,
+ article: string,
+ signerName: string,
+): string {
+ const has = makeHas(signals)
+ const editingSoftware = getEditingSoftware(manifest)
+ const creator = editingSoftware[0] || signerName
+
+ // Fully GenAI overrides everything else — the asset *is* AI output.
+ if (has(INCEPTION.FULLY_GENAI)) {
+ return creator
+ ? `This is ${article} ${mediaWord} generated by ${creator}`
+ : `This is an AI-generated ${mediaWord}`
}
- // Check individual actions
- const actions = getAllActions(manifest)
- for (const action of actions) {
- if (action.digitalSourceType) {
- return getSourceType(action.digitalSourceType)
+ // Captured media (with optional camera precision from EXIF).
+ if (has(INCEPTION.CAPTURED_MEDIA) || has(INCEPTION.CAPTURED_MEDIA_STITCHED)) {
+ const camera = getCameraInfo(manifest)
+ if (camera) {
+ const stitched = has(INCEPTION.CAPTURED_MEDIA_STITCHED)
+ return stitched
+ ? `This is a stitched photo taken with a ${camera}`
+ : `This is a photo taken with a ${camera}`
}
+ return `This is a captured ${mediaWord}`
}
- // Check creative work assertion
- const creativeWork = getAssertionData(manifest, 'stds.schema.org/CreativeWork')
- if (creativeWork?.digitalSourceType) {
- return getSourceType(creativeWork.digitalSourceType as unknown as string)
+ // Screen capture (rubric framing: "may contain GenAI"); we keep it short.
+ if (has(INCEPTION.SCREEN_CAPTURE_MAY_GENAI)) {
+ return `This is a screen capture`
}
- return ''
+ // Hand-crafted digital art / blank canvas / non-GenAI digital creation.
+ if (has(INCEPTION.NON_GENAI_DIGITAL_CREATION) || has(INCEPTION.BLANK_CANVAS)) {
+ return creator
+ ? `This is a digital artwork created with ${creator}`
+ : `This is a digital artwork`
+ }
+
+ // Composite with GenAI mixed in — the asset isn't fully synthetic but is
+ // partly so. Sentence stays neutral; the modification clause carries the
+ // "uses GenAI" detail.
+ if (has(INCEPTION.PARTLY_GENAI) || has(INCEPTION.COMPOSITION_MAY_GENAI)) {
+ return creator
+ ? `This is ${article} ${mediaWord} composed by ${creator}`
+ : `This is ${article} composed ${mediaWord}`
+ }
+
+ // Generic fallback — no inception signal fired. We say what we can without
+ // claiming the asset is anything specific.
+ return creator
+ ? `This is ${article} ${mediaWord} from ${creator}`
+ : `This is ${article} ${mediaWord}`
}
-function getSoftwareAgentName(agent: string | { name?: string } | undefined): string {
- if (!agent) return ''
- if (typeof agent === 'string') return cleanName(agent)
- return cleanName(agent.name ?? '')
+// ── Modification phrases ──────────────────────────────────────────────
+
+function buildModificationPhrases(
+ manifest: CrJsonManifestEntry,
+ signals: ManifestSignalsResult | null,
+): string[] {
+ const has = makeHas(signals)
+ const phrases: string[] = []
+
+ // GenAI usage takes precedence over editorial-non-AI: if both fire, the
+ // GenAI signal is the one we want to surface.
+ if (has(TRANSFORMATION.EDITORIAL_AI) || has(TRANSFORMATION.EDITORIAL_POSSIBLY_GENAI)) {
+ phrases.push('modified using generative AI')
+ } else if (has(TRANSFORMATION.EDITORIAL_NON_AI)) {
+ const tools = getEditingSoftware(manifest).slice(0, 2).join(' and ')
+ phrases.push(tools ? `edited in ${tools}` : 'edited')
+ }
+
+ if (has(TRANSFORMATION.NON_EDITORIAL)) {
+ phrases.push('converted to a different format')
+ }
+
+ return phrases
}
+// ── Signal lookup helper ──────────────────────────────────────────────
+
+/**
+ * Returns a closure `has(traitId)` that's true when the signals rubric
+ * fired that trait on this manifest. When `signals` is null (rubric not
+ * loaded), every check returns false — the generic fallback then takes
+ * over so we never make claims we can't back up.
+ */
+function makeHas(signals: ManifestSignalsResult | null) {
+ if (!signals) return () => false
+ // Cache the union once; `has()` is called many times per render.
+ const traits = new Set([
+ ...signals.localInceptions.map((s) => s.trait),
+ ...signals.localTransformations.map((s) => s.trait),
+ ])
+ return (id: string) => traits.has(id)
+}
+
+// ── Manifest field extractors (precision the rubric doesn't carry) ────
+
+interface AssertionData {
+ digitalSourceType?: string
+ make?: string
+ model?: string
+ softwareAgent?: string | { name?: string }
+ actions?: Array<{
+ action?: string
+ softwareAgent?: string | { name?: string }
+ }>
+}
+
+function getAssertionData(manifest: CrJsonManifestEntry, label: string): AssertionData | null {
+ return (getAssertionDataByLabel(manifest, label) as AssertionData | undefined) ?? null
+}
+
+/** Names of every `softwareAgent` referenced from any action. Order-preserving, deduplicated. */
function getEditingSoftware(manifest: CrJsonManifestEntry): string[] {
const tools = new Set()
- const actions = getAllActions(manifest)
- for (const action of actions) {
- if (action.softwareAgent) {
- const name = getSoftwareAgentName(action.softwareAgent)
+ // Collect from all assertions whose payload looks like an actions list.
+ for (const { data } of getAssertionsList(manifest)) {
+ const actions = (data as AssertionData)?.actions
+ if (!Array.isArray(actions)) continue
+ for (const a of actions) {
+ const name = softwareAgentName(a.softwareAgent)
+ if (name) tools.add(name)
+ }
+ }
+ // Also check the `c2pa.actions` shape directly in case the iterator missed it.
+ const actionsAssertion = getAssertionData(manifest, 'c2pa.actions')
+ if (actionsAssertion?.actions) {
+ for (const a of actionsAssertion.actions) {
+ const name = softwareAgentName(a.softwareAgent)
if (name) tools.add(name)
}
}
return [...tools]
}
+function softwareAgentName(agent: AssertionData['softwareAgent']): string {
+ if (!agent) return ''
+ return cleanAgentName(typeof agent === 'string' ? agent : (agent.name ?? ''))
+}
+
+/** Strip `Tool/version` and `Tool 1.0.0` suffixes; trim whitespace. */
+function cleanAgentName(name: string): string {
+ if (!name) return ''
+ return name.split('/')[0].trim()
+}
+
+/**
+ * Best-effort camera identification from any assertion that carries
+ * `make`/`model`. We scan all assertions because the field appears under
+ * various labels (`stds.exif`, `c2pa.exif`, etc.) and we don't want to
+ * hardcode a specific one.
+ */
function getCameraInfo(manifest: CrJsonManifestEntry): string {
for (const { data } of getAssertionsList(manifest)) {
const d = data as AssertionData
- if (d?.make || d?.model) {
- const make = d.make ?? ''
- const model = d.model ?? ''
- if (make && model) return `${make} ${model}`
- if (make) return `${make} camera`
- if (model) return model
- }
+ if (!d?.make && !d?.model) continue
+ const make = d.make ?? ''
+ const model = d.model ?? ''
+ if (make && model) return `${make} ${model}`
+ if (make) return `${make} camera`
+ if (model) return model
}
return ''
}
+// ── Identity / signer extractors ──────────────────────────────────────
+
function getSignerName(manifest: CrJsonManifestEntry, usedITL: boolean): string {
- const claimInfo = getClaimInfo(manifest)
- const sigInfo = getSignatureInfo(manifest)
+ // When the ITL signed the cert, prefer the claim-generator's friendly
+ // name over the cert CN — the CN is often the ITL's own identity, not
+ // the asset's creator.
if (usedITL) {
- const generatorName = claimInfo?.claim_generator_info?.[0]?.name
- if (generatorName) return cleanName(generatorName)
+ const generator = getClaimInfo(manifest)?.claim_generator_info?.[0]?.name
+ if (generator) return cleanAgentName(generator)
}
- return sigInfo?.common_name ?? ''
+ return getSignatureInfo(manifest)?.common_name ?? ''
}
function getClaimGeneratorName(manifest: CrJsonManifestEntry): string {
- const claimInfo = getClaimInfo(manifest)
- const generatorName = claimInfo?.claim_generator_info?.[0]?.name
- if (generatorName) return cleanName(generatorName)
- return ''
+ const name = getClaimInfo(manifest)?.claim_generator_info?.[0]?.name
+ return name ? cleanAgentName(name) : ''
}
function getCommonName(manifest: CrJsonManifestEntry): string {
return getSignatureInfo(manifest)?.common_name ?? ''
}
-function hasAIActions(manifest: CrJsonManifestEntry): boolean {
- const actions = getAllActions(manifest)
- return actions.some(a => a.action === ACTION.AI_GENERATED)
-}
-
-function getHumanReadableActions(manifest: CrJsonManifestEntry): string[] {
- const actions = getAllActions(manifest)
- const descriptions: string[] = []
+// ── Grammar / composition helpers ─────────────────────────────────────
- const editActions = [ACTION.EDITED, ACTION.FILTERED, ACTION.COLOR_ADJUSTED, ACTION.CROPPED, ACTION.RESIZED, ACTION.DREW]
- const hasEdit = actions.some(a => editActions.includes(a.action as typeof editActions[number]))
- if (hasEdit) descriptions.push('edited')
-
- if (actions.some(a => a.action === ACTION.CONVERTED || a.action === ACTION.TRANSCODED || a.action === ACTION.REPACKAGED)) {
- descriptions.push('converted')
- }
-
- return descriptions
+function getMediaWord(mimeType: string): string {
+ if (mimeType.startsWith('image/')) return 'image'
+ if (mimeType.startsWith('video/')) return 'video'
+ if (mimeType.startsWith('audio/')) return 'audio file'
+ if (mimeType === 'application/pdf') return 'document'
+ return 'file'
}
-export interface ManifestSummary {
- sentence: string
- details: string[]
+/** Pick `a` vs `an` based on the next word's leading sound. */
+function articleFor(nextWord: string): string {
+ return /^[aeiou]/i.test(nextWord) ? 'an' : 'a'
}
-export function generateManifestSummary(
- manifest: CrJsonManifestEntry | null | undefined,
- ingredients: CrJsonIngredientItem[],
- mimeType: string,
- usedITL: boolean = false,
- isTrusted: boolean = true,
-): ManifestSummary {
- if (!manifest) return { sentence: '', details: [] }
-
- const mediaWord = getMediaWord(mimeType)
- const sourceType = getPrimaryDigitalSourceType(manifest)
- const signerName = getSignerName(manifest, usedITL)
- const cameraInfo = getCameraInfo(manifest)
- const editingSoftware = getEditingSoftware(manifest)
- const aiActions = hasAIActions(manifest)
- const humanActions = getHumanReadableActions(manifest)
-
- const parts: string[] = []
- const details: string[] = []
-
- if (!isTrusted) {
- // For untrusted signatures: "This is a [media] from [claim_generator] signed by [common_name]"
- const claimGenerator = getClaimGeneratorName(manifest)
- const commonName = getCommonName(manifest)
- const articleSuffix = mimeType.startsWith('image/') ? 'n' : ''
- let originPhrase = `This is a${articleSuffix} ${mediaWord}`
- if (claimGenerator) originPhrase += ` from ${claimGenerator}`
- if (commonName) originPhrase += ` signed by ${commonName}`
- parts.push(originPhrase)
- } else {
- // --- Determine the origin phrase ---
- const isFullyAIGenerated = sourceType === SOURCE_TYPE.TRAINED_ALGORITHMIC || sourceType === SOURCE_TYPE.ALGORITHMIC_MEDIA
- const hasAIComposite = sourceType === SOURCE_TYPE.COMPOSITE_AI || aiActions
- const isCameraCapture = sourceType === SOURCE_TYPE.DIGITAL_CAPTURE || !!cameraInfo
-
- if (isFullyAIGenerated) {
- // "This is an AI-generated image"
- const creator = editingSoftware[0] || signerName
- if (creator) {
- parts.push(`This is a${mimeType.startsWith('image/') ? 'n' : ''} ${mediaWord} generated by ${creator}`)
- } else {
- parts.push(`This is an AI-generated ${mediaWord}`)
- }
- } else if (isCameraCapture) {
- // "This is a photo from a Sony camera"
- if (cameraInfo) {
- parts.push(`This is a photo taken with a ${cameraInfo}`)
- } else {
- parts.push(`This is a captured ${mediaWord}`)
- }
- } else if (sourceType === SOURCE_TYPE.DIGITAL_ART) {
- const creator = editingSoftware[0] || signerName
- parts.push(creator ? `This is a digital artwork created with ${creator}` : `This is a digital artwork`)
- } else if (sourceType === SOURCE_TYPE.SCREEN_CAPTURE) {
- parts.push(`This is a screen capture`)
- } else {
- // Generic fallback
- const creator = editingSoftware[0] || signerName
- parts.push(creator ? `This is a${mimeType.startsWith('image/') ? 'n' : ''} ${mediaWord} from ${creator}` : `This is a${mimeType.startsWith('image/') ? 'n' : ''} ${mediaWord}`)
- }
-
- // --- Determine modifications ---
- const modifications: string[] = []
-
- if (hasAIComposite) {
- modifications.push('modified using generative AI')
- } else if (humanActions.includes('edited') && editingSoftware.length > 0) {
- const toolList = editingSoftware.slice(0, 2).join(' and ')
- modifications.push(`edited in ${toolList}`)
- } else if (humanActions.includes('edited')) {
- modifications.push('edited')
- }
-
- if (humanActions.includes('converted')) {
- modifications.push('converted to a different format')
- }
-
- if (modifications.length > 0) {
- parts.push(modifications.join(' and '))
- }
-
- }
-
- // --- Certificate issuer (from crJSON manifest.signature) ---
- const issuer = getSignatureInfo(manifest)?.issuer
- if (issuer) {
- details.push(`Certificate issued by ${issuer}`)
- }
-
- // --- Ingredient provenance ---
- const ingredientCount = ingredients?.length ?? 0
- if (ingredientCount > 0) {
- details.push(`Based on ${ingredientCount} source asset${ingredientCount > 1 ? 's' : ''}`)
- }
-
- // Combine parts into a sentence
- let sentence = ''
- if (parts.length === 1) {
- sentence = parts[0] + '.'
- } else if (parts.length === 2) {
- sentence = parts[0] + ', ' + parts[1] + '.'
- } else if (parts.length > 2) {
- sentence = parts.slice(0, -1).join(', ') + ', and ' + parts[parts.length - 1] + '.'
- }
-
- // Capitalize first letter
- sentence = sentence.charAt(0).toUpperCase() + sentence.slice(1)
+function joinAnd(items: string[]): string {
+ if (items.length <= 1) return items[0] ?? ''
+ if (items.length === 2) return `${items[0]} and ${items[1]}`
+ return `${items.slice(0, -1).join(', ')}, and ${items[items.length - 1]}`
+}
- return { sentence, details }
+function composeSentence(parts: string[]): string {
+ if (parts.length === 0) return ''
+ let s: string
+ if (parts.length === 1) s = parts[0]
+ else if (parts.length === 2) s = `${parts[0]}, ${parts[1]}`
+ else s = `${parts.slice(0, -1).join(', ')}, and ${parts[parts.length - 1]}`
+ return s.charAt(0).toUpperCase() + s.slice(1) + '.'
}
diff --git a/src/lib/profileEvaluator.ts b/src/lib/profileEvaluator.ts
deleted file mode 100644
index fc83795..0000000
--- a/src/lib/profileEvaluator.ts
+++ /dev/null
@@ -1,181 +0,0 @@
-type ProfileEvaluatorModule = {
- default: () => Promise
- /** Returns a JSON string (report); parse with JSON.parse so the UI gets a plain object with .statements etc. */
- evaluate_profile_wasm: (profileYaml: string, indicatorsJson: string) => unknown
-}
-
-export type EvaluateProfileResult =
- | { success: true; result: unknown }
- | { success: false; error: string; detail?: string }
-
-const base =
- typeof import.meta.env?.BASE_URL === 'string' ? import.meta.env.BASE_URL : '/'
-const profileEvaluatorModuleUrl = `${base}profile-evaluator/profile_evaluator_rs.js`
-
-/** URL the app uses to load the profile-evaluator WASM (for debugging). */
-export const PROFILE_EVALUATOR_SCRIPT_URL = profileEvaluatorModuleUrl
-
-const importModule = new Function('modulePath', 'return import(modulePath)') as (
- modulePath: string
-) => Promise
-
-let evaluatorModulePromise: Promise | null = null
-let evaluatorModuleLoadError: string | null = null
-
-/** Clear cached load state so the next verify or evaluate will try loading the WASM again. */
-export function resetProfileEvaluatorLoad(): void {
- evaluatorModulePromise = null
- evaluatorModuleLoadError = null
-}
-
-async function loadProfileEvaluatorModule(): Promise {
- if (evaluatorModuleLoadError) return null
- if (!evaluatorModulePromise) {
- evaluatorModulePromise = (async () => {
- try {
- // Try dynamic import directly; some servers don't send application/javascript for .js
- // or HEAD may fail, so we don't rely on a prior HEAD check.
- const module = await importModule(profileEvaluatorModuleUrl)
- await module.default()
- return module
- } catch (err: unknown) {
- const msg = err instanceof Error ? err.message : String(err)
- evaluatorModuleLoadError = msg
- evaluatorModulePromise = null
- if (import.meta.env?.DEV) {
- console.warn('[profile-evaluator] load failed:', msg, err)
- }
- return null
- }
- })()
- }
- return evaluatorModulePromise
-}
-
-/** Minimal profile + indicators that produce a non-empty report (one statement). */
-const WASM_VERIFY_PROFILE = `---
-profile_metadata:
- language: en
----
-- id: wasm_verify
- title: WASM verification
- expression: "1 + 1"
- report_text:
- en: "2 = {{ expr \\"1+1\\" }}"
-`
-
-const WASM_VERIFY_INDICATORS = {}
-
-export type VerifyWasmResult =
- | { ok: true; result: unknown; rawType: string }
- | { ok: false; error: string; detail?: string }
-
-/**
- * Verifies the profile-evaluator WASM loads and runs correctly by evaluating
- * a minimal profile with known-good inputs. Call this to confirm the WASM is working.
- */
-export async function verifyWasm(): Promise {
- const module = await loadProfileEvaluatorModule()
- if (!module) {
- return {
- ok: false,
- error: 'WASM not loaded',
- detail:
- evaluatorModuleLoadError ??
- 'Profile evaluator module not available.',
- }
- }
- try {
- const raw = module.evaluate_profile_wasm(
- WASM_VERIFY_PROFILE,
- JSON.stringify(WASM_VERIFY_INDICATORS)
- )
- // WASM returns a JSON string; parse so we get a plain object with .statements etc.
- let report: unknown = raw
- if (typeof raw === 'string') {
- try {
- report = JSON.parse(raw) as unknown
- } catch (e) {
- return {
- ok: false,
- error: 'WASM returned invalid JSON',
- detail: e instanceof Error ? e.message : String(e),
- }
- }
- }
- const rawType = typeof raw
- const hasKeys =
- report != null &&
- typeof report === 'object' &&
- !Array.isArray(report) &&
- Object.keys(report as object).length > 0
- if (import.meta.env?.DEV) {
- console.log('[profile-evaluator] verifyWasm:', {
- rawType,
- keys: report != null && typeof report === 'object' ? Object.keys(report as object) : [],
- })
- }
- if (!hasKeys) {
- return {
- ok: false,
- error: 'WASM returned empty result',
- detail: `Expected non-empty report from minimal profile. Parsed type: ${typeof report}, keys: ${report != null && typeof report === 'object' ? Object.keys(report as object).join(', ') || 'none' : 'n/a'}.`,
- }
- }
- return { ok: true, result: report, rawType: typeof report }
- } catch (err) {
- const message = err instanceof Error ? err.message : String(err)
- return {
- ok: false,
- error: 'WASM verification failed',
- detail: message,
- }
- }
-}
-
-/**
- * Evaluate a YAML profile against crJSON indicators.
- * If the profile-evaluator WASM module is not available, returns a structured
- * result with success: false and an error message instead of throwing.
- */
-export async function evaluateProfile(
- profileYaml: string,
- indicators: unknown
-): Promise {
- const evaluatorModule = await loadProfileEvaluatorModule()
- if (!evaluatorModule) {
- return {
- success: false,
- error: 'Profile evaluator WASM not available',
- detail:
- evaluatorModuleLoadError ??
- 'Run "npm run copy:profile-evaluator" to copy the profile evaluator from a sibling profile-evaluator-rs repo into public/profile-evaluator/.',
- }
- }
- try {
- // WASM returns a JSON string (serde_json::to_string); parse so the UI gets a plain object with .statements etc.
- const raw = evaluatorModule.evaluate_profile_wasm(
- profileYaml,
- JSON.stringify(indicators)
- )
- let result: unknown
- if (typeof raw === 'string') {
- try {
- result = JSON.parse(raw) as unknown
- } catch {
- result = raw
- }
- } else {
- result = raw
- }
- if (import.meta.env?.DEV) {
- console.log('[profile-evaluator] evaluate_profile_wasm:', {
- keys: result != null && typeof result === 'object' && !Array.isArray(result) ? Object.keys(result as object) : [],
- })
- }
- return { success: true, result }
- } catch (err) {
- const message = err instanceof Error ? err.message : String(err)
- return { success: false, error: 'Profile evaluation failed', detail: message }
- }
-}
diff --git a/src/lib/rubrics/__fixtures__/audio-mixed-with-genai.conformance.json b/src/lib/rubrics/__fixtures__/audio-mixed-with-genai.conformance.json
new file mode 100644
index 0000000..c17a9eb
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/audio-mixed-with-genai.conformance.json
@@ -0,0 +1,71 @@
+{
+ "rubricName": "C2PA Asset Conformance 0.1 Spec 2.2 Rubric",
+ "rubricVersion": "0.1.0",
+ "true": {
+ "validation": [
+ {
+ "trait": "active_manifest_urn",
+ "reportText": "Active manifest URN uses standard 'urn:c2pa:' prefix"
+ },
+ {
+ "trait": "inception_action_position",
+ "reportText": "Inception action is correctly positioned as the first item in the first created actions assertion"
+ },
+ {
+ "trait": "ingredient_relationship_values",
+ "reportText": "All ingredient assertions have valid relationship values"
+ },
+ {
+ "trait": "ingredient_v3_no_active_manifest",
+ "reportText": "Ingredient v3 assertions without activeManifest correctly omit validationResults"
+ },
+ {
+ "trait": "no_deprecated_actions",
+ "reportText": "No deprecated actions used"
+ },
+ {
+ "trait": "no_deprecated_assertions",
+ "reportText": "No deprecated standard assertions found"
+ },
+ {
+ "trait": "no_unsupported_assertions",
+ "reportText": "All standard assertions are supported for Spec 2.2"
+ },
+ {
+ "trait": "review_ratings_datasource",
+ "reportText": "No invalid co-occurrence of reviewRatings and human entry dataSource"
+ },
+ {
+ "trait": "trusted_data_present",
+ "reportText": "Validation results are present for trust analysis"
+ },
+ {
+ "trait": "trusted_success",
+ "reportText": "Asset is trusted"
+ },
+ {
+ "trait": "update_manifest_constraints",
+ "reportText": "Update manifest constraints are satisfied (or manifest is not an update manifest)"
+ },
+ {
+ "trait": "valid_data_present",
+ "reportText": "Validation results are present for integrity analysis"
+ },
+ {
+ "trait": "valid_success",
+ "reportText": "No validation mismatches found"
+ },
+ {
+ "trait": "well_formed_data_present",
+ "reportText": "Validation results are present for structural analysis"
+ },
+ {
+ "trait": "well_formed_success",
+ "reportText": "No structural failures found"
+ }
+ ]
+ },
+ "false": {
+ "validation": []
+ }
+}
diff --git a/src/lib/rubrics/__fixtures__/audio-mixed-with-genai.json b/src/lib/rubrics/__fixtures__/audio-mixed-with-genai.json
new file mode 100644
index 0000000..8d329c7
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/audio-mixed-with-genai.json
@@ -0,0 +1,411 @@
+{
+ "@context": {
+ "@vocab": "https://contentcredentials.org/crjson",
+ "extras": "https://contentcredentials.org/crjson/extras"
+ },
+ "manifests": [
+ {
+ "label": "urn:c2pa:a2b680ae-e454-2302-cbf8-46b5d3e407b4",
+ "assertions": {
+ "c2pa.hash.bmff.v3": {
+ "exclusions": [
+ {
+ "xpath": "/ftyp"
+ },
+ {
+ "xpath": "/uuid",
+ "data": [
+ {
+ "offset": 8,
+ "value": [
+ 216,
+ 254,
+ 195,
+ 214,
+ 27,
+ 14,
+ 72,
+ 60,
+ 146,
+ 151,
+ 88,
+ 40,
+ 135,
+ 126,
+ 196,
+ 129
+ ]
+ }
+ ]
+ },
+ {
+ "xpath": "/mfra"
+ },
+ {
+ "xpath": "/free"
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'Ux5Rz5ubwnuUIIoHhVRXdctAKpn6acNujT22vMBJBF0=",
+ "name": "BMFF file hash",
+ "pad": "b64'"
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.opened",
+ "parameters": {
+ "ingredients": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'wrGfYTzqcn+suUUwhQhscgSRNwvMIsWIrETRWC2cXEE=",
+ "pad": "b64'"
+ }
+ ]
+ }
+ },
+ {
+ "action": "c2pa.dubbed",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeSynthetic"
+ }
+ ]
+ },
+ "c2pa.ingredient.v3": {
+ "relationship": "parentOf",
+ "validationResults": {
+ "activeManifest": {
+ "success": [
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:6b16b24d-4eba-60b5-0218-469a7ab9c73a/c2pa.signature"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:6b16b24d-4eba-60b5-0218-469a7ab9c73a/c2pa.signature"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:6b16b24d-4eba-60b5-0218-469a7ab9c73a/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:6b16b24d-4eba-60b5-0218-469a7ab9c73a/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:6b16b24d-4eba-60b5-0218-469a7ab9c73a/c2pa.signature"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:6b16b24d-4eba-60b5-0218-469a7ab9c73a/c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:6b16b24d-4eba-60b5-0218-469a7ab9c73a/c2pa.assertions/c2pa.hash.bmff.v3"
+ },
+ {
+ "code": "assertion.bmffHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:6b16b24d-4eba-60b5-0218-469a7ab9c73a/c2pa.assertions/c2pa.hash.bmff.v3"
+ }
+ ],
+ "informational": [
+ {
+ "code": "signingCredential.ocsp.skipped",
+ "url": "self#jumbf=/c2pa/urn:c2pa:6b16b24d-4eba-60b5-0218-469a7ab9c73a/c2pa.signature"
+ }
+ ],
+ "failure": []
+ }
+ },
+ "activeManifest": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:6b16b24d-4eba-60b5-0218-469a7ab9c73a",
+ "hash": "b64'cykuU1M2Tb/0uyFDDEoHWeef1AiO1AK16PXvEzSgZyk=",
+ "pad": "b64'"
+ },
+ "claimSignature": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:6b16b24d-4eba-60b5-0218-469a7ab9c73a/c2pa.signature",
+ "hash": "b64'TBF0HEF8zMnOH8xrDUOfb0hEsCZ1VT8q3yKlpNuXTA8=",
+ "pad": "b64'"
+ }
+ }
+ },
+ "claim.v2": {
+ "instanceID": "2e1ebd20-c38f-182f-ca20-4783b606bc7a",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:a2b680ae-e454-2302-cbf8-46b5d3e407b4/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA SDK for Android",
+ "version": "852130983:867906598"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'wrGfYTzqcn+suUUwhQhscgSRNwvMIsWIrETRWC2cXEE="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'OZ3pss9ZPcxyUinHZHQr7ozQagxiVk1/ky/wCu5Urcs="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3",
+ "hash": "b64'gowprNbZfHrW+1IgggMrOM/dlDD9BPHl5lD/j0Jvr0w="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "46c75c60ba9c323de2720b726b45dde36c8135",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Mobile A 1P ICA G3 L4"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "OU": "Android",
+ "CN": "Pixel Recorder"
+ },
+ "validity": {
+ "notBefore": "2026-03-02T09:19:20+00:00",
+ "notAfter": "2026-04-01T09:19:19+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2026-03-04T00:40:25+00:00",
+ "certificateInfo": {
+ "serialNumber": "801dd6ca0e3a76f622533f629162f7faa703c9",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Pixel Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google",
+ "CN": "Google Pixel Time Stamping Authority"
+ },
+ "validity": {
+ "notBefore": "2025-05-12T23:43:02+00:00",
+ "notAfter": "2032-11-11T08:43:01+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:a2b680ae-e454-2302-cbf8-46b5d3e407b4/c2pa.signature",
+ "explanation": "timestamp message digest matched: Google Pixel Time Stamping Authority"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:a2b680ae-e454-2302-cbf8-46b5d3e407b4/c2pa.signature",
+ "explanation": "timestamp cert trusted: Google Pixel Time Stamping Authority"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:a2b680ae-e454-2302-cbf8-46b5d3e407b4/c2pa.signature",
+ "explanation": "signing certificate trusted, found in System trust anchors"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:a2b680ae-e454-2302-cbf8-46b5d3e407b4/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:a2b680ae-e454-2302-cbf8-46b5d3e407b4/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:a2b680ae-e454-2302-cbf8-46b5d3e407b4/c2pa.assertions/c2pa.ingredient.v3",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:a2b680ae-e454-2302-cbf8-46b5d3e407b4/c2pa.assertions/c2pa.actions.v2",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:a2b680ae-e454-2302-cbf8-46b5d3e407b4/c2pa.assertions/c2pa.hash.bmff.v3",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3"
+ },
+ {
+ "code": "assertion.bmffHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:a2b680ae-e454-2302-cbf8-46b5d3e407b4/c2pa.assertions/c2pa.hash.bmff.v3",
+ "explanation": "BMFF hash valid"
+ }
+ ],
+ "informational": [],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-03-31T06:28:36.137+00:00"
+ },
+ "ingredientDeltas": [
+ {
+ "ingredientAssertionURI": "self#jumbf=/c2pa/urn:c2pa:a2b680ae-e454-2302-cbf8-46b5d3e407b4/c2pa.assertions/c2pa.ingredient.v3",
+ "validationDeltas": {
+ "success": [
+ {
+ "code": "ingredient.manifest.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:6b16b24d-4eba-60b5-0218-469a7ab9c73a",
+ "explanation": "ingredient hash matched"
+ }
+ ],
+ "informational": [],
+ "failure": []
+ }
+ }
+ ]
+ },
+ {
+ "label": "urn:c2pa:6b16b24d-4eba-60b5-0218-469a7ab9c73a",
+ "assertions": {
+ "c2pa.hash.bmff.v3": {
+ "exclusions": [
+ {
+ "xpath": "/ftyp"
+ },
+ {
+ "xpath": "/uuid",
+ "data": [
+ {
+ "offset": 8,
+ "value": [
+ 216,
+ 254,
+ 195,
+ 214,
+ 27,
+ 14,
+ 72,
+ 60,
+ 146,
+ 151,
+ 88,
+ 40,
+ 135,
+ 126,
+ 196,
+ 129
+ ]
+ }
+ ]
+ },
+ {
+ "xpath": "/mfra"
+ },
+ {
+ "xpath": "/free"
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'lMaJ8VQCEl71zLB1jph2Da7lb/5JwhI6NFknB2Svp/M=",
+ "name": "BMFF file hash",
+ "pad": "b64'"
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.created",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture"
+ }
+ ]
+ }
+ },
+ "claim.v2": {
+ "instanceID": "e71a9a36-803b-c1fb-a749-407b13ca758e",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:6b16b24d-4eba-60b5-0218-469a7ab9c73a/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA SDK for Android",
+ "version": "852130983:867906598"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'Qc/GfIFN7B3jbO7vQGR1KhL28apGvWum5Vgq4Kp44I8="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3",
+ "hash": "b64'kjLaiX3kOeck0AapbM4yfQcFbEJRDhdIcXfq+13KPHI="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "a7d0f59d0e0866958134ca36aec4b0925b17ce",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Mobile A 1P ICA G3 L1"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "OU": "Android",
+ "CN": "Pixel Recorder"
+ },
+ "validity": {
+ "notBefore": "2026-03-02T08:17:07+00:00",
+ "notAfter": "2026-04-01T08:17:06+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2026-03-03T23:33:50+00:00",
+ "certificateInfo": {
+ "serialNumber": "801dd6ca0e3a76f622533f629162f7faa703c9",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Pixel Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google",
+ "CN": "Google Pixel Time Stamping Authority"
+ },
+ "validity": {
+ "notBefore": "2025-05-12T23:43:02+00:00",
+ "notAfter": "2032-11-11T08:43:01+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [
+ {
+ "code": "ingredient.manifest.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:6b16b24d-4eba-60b5-0218-469a7ab9c73a",
+ "explanation": "ingredient hash matched"
+ }
+ ],
+ "informational": [],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-03-31T06:28:36.137+00:00"
+ }
+ }
+ ],
+ "jsonGenerator": {
+ "name": "c2pa-rs",
+ "version": "0.78.0"
+ },
+ "usedITL": false,
+ "usedTestCerts": false,
+ "_conformanceToolVersion": {
+ "commit": "d291fb884e02665d169a38928392c2710f6f0953",
+ "shortCommit": "d291fb8",
+ "date": "2026-03-19 20:11:48 -0400",
+ "branch": "HEAD",
+ "generatedAt": "2026-03-23T18:42:00.448Z"
+ }
+}
\ No newline at end of file
diff --git a/src/lib/rubrics/__fixtures__/audio-mixed-with-genai.signals.json b/src/lib/rubrics/__fixtures__/audio-mixed-with-genai.signals.json
new file mode 100644
index 0000000..6868930
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/audio-mixed-with-genai.signals.json
@@ -0,0 +1,47 @@
+{
+ "rubricName": "C2PA Asset Signals Rubric (Local)",
+ "rubricVersion": "1.0.0",
+ "manifests": [
+ {
+ "assertedBy": {
+ "CN": "Pixel Recorder",
+ "O": "Google LLC",
+ "OU": "Android"
+ },
+ "mimeType": null,
+ "localInceptions": [],
+ "localTransformations": [
+ {
+ "trait": "transformation:signal_editorialAI",
+ "reportText": "Contains Editorial GenAI Transformations",
+ "multiple": false
+ }
+ ],
+ "allActionsIncluded": false,
+ "ingredients": [
+ {
+ "index": 1,
+ "relationship": "parentOf"
+ }
+ ]
+ },
+ {
+ "assertedBy": {
+ "CN": "Pixel Recorder",
+ "O": "Google LLC",
+ "OU": "Android"
+ },
+ "mimeType": null,
+ "localInceptions": [
+ {
+ "trait": "inception:signal_capturedMedia",
+ "reportText": "Contains Captured Media",
+ "multiple": false
+ }
+ ],
+ "localTransformations": [],
+ "allActionsIncluded": false,
+ "ingredients": []
+ }
+ ]
+}
diff --git a/src/lib/rubrics/__fixtures__/capture-ai-and-non-ai-edits.conformance.json b/src/lib/rubrics/__fixtures__/capture-ai-and-non-ai-edits.conformance.json
new file mode 100644
index 0000000..c17a9eb
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/capture-ai-and-non-ai-edits.conformance.json
@@ -0,0 +1,71 @@
+{
+ "rubricName": "C2PA Asset Conformance 0.1 Spec 2.2 Rubric",
+ "rubricVersion": "0.1.0",
+ "true": {
+ "validation": [
+ {
+ "trait": "active_manifest_urn",
+ "reportText": "Active manifest URN uses standard 'urn:c2pa:' prefix"
+ },
+ {
+ "trait": "inception_action_position",
+ "reportText": "Inception action is correctly positioned as the first item in the first created actions assertion"
+ },
+ {
+ "trait": "ingredient_relationship_values",
+ "reportText": "All ingredient assertions have valid relationship values"
+ },
+ {
+ "trait": "ingredient_v3_no_active_manifest",
+ "reportText": "Ingredient v3 assertions without activeManifest correctly omit validationResults"
+ },
+ {
+ "trait": "no_deprecated_actions",
+ "reportText": "No deprecated actions used"
+ },
+ {
+ "trait": "no_deprecated_assertions",
+ "reportText": "No deprecated standard assertions found"
+ },
+ {
+ "trait": "no_unsupported_assertions",
+ "reportText": "All standard assertions are supported for Spec 2.2"
+ },
+ {
+ "trait": "review_ratings_datasource",
+ "reportText": "No invalid co-occurrence of reviewRatings and human entry dataSource"
+ },
+ {
+ "trait": "trusted_data_present",
+ "reportText": "Validation results are present for trust analysis"
+ },
+ {
+ "trait": "trusted_success",
+ "reportText": "Asset is trusted"
+ },
+ {
+ "trait": "update_manifest_constraints",
+ "reportText": "Update manifest constraints are satisfied (or manifest is not an update manifest)"
+ },
+ {
+ "trait": "valid_data_present",
+ "reportText": "Validation results are present for integrity analysis"
+ },
+ {
+ "trait": "valid_success",
+ "reportText": "No validation mismatches found"
+ },
+ {
+ "trait": "well_formed_data_present",
+ "reportText": "Validation results are present for structural analysis"
+ },
+ {
+ "trait": "well_formed_success",
+ "reportText": "No structural failures found"
+ }
+ ]
+ },
+ "false": {
+ "validation": []
+ }
+}
diff --git a/src/lib/rubrics/__fixtures__/capture-ai-and-non-ai-edits.json b/src/lib/rubrics/__fixtures__/capture-ai-and-non-ai-edits.json
new file mode 100644
index 0000000..62589ea
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/capture-ai-and-non-ai-edits.json
@@ -0,0 +1,404 @@
+{
+ "@context": {
+ "@vocab": "https://contentcredentials.org/crjson",
+ "extras": "https://contentcredentials.org/crjson/extras"
+ },
+ "manifests": [
+ {
+ "label": "urn:c2pa:7781b3f0-c26c-f617-2565-4e8687c2b753",
+ "assertions": {
+ "c2pa.hash.data": {
+ "exclusions": [
+ {
+ "start": 7914,
+ "length": 11921
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'wX+xWKfr47WQ0+CoSAVEc+VByyifF1rBibNwvseFpio=",
+ "pad": "b64'AAAAAAAAAAAAAAAA"
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.opened",
+ "parameters": {
+ "ingredients": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'SdSVV2KWCssld25s1HaJd1alYQ4hICNuMR4Yl9icL50=",
+ "pad": "b64'"
+ }
+ ]
+ }
+ },
+ {
+ "action": "c2pa.adjustedColor",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/algorithmicallyEnhanced"
+ },
+ {
+ "action": "c2pa.edited",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia"
+ }
+ ]
+ },
+ "c2pa.ingredient.v3": {
+ "relationship": "parentOf",
+ "validationResults": {
+ "activeManifest": {
+ "success": [
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:eecfbd16-c3ee-4ea0-4337-45682d534294/c2pa.signature"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:eecfbd16-c3ee-4ea0-4337-45682d534294/c2pa.signature"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:eecfbd16-c3ee-4ea0-4337-45682d534294/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:eecfbd16-c3ee-4ea0-4337-45682d534294/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:eecfbd16-c3ee-4ea0-4337-45682d534294/c2pa.signature"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:eecfbd16-c3ee-4ea0-4337-45682d534294/c2pa.assertions/c2pa.hash.data"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:eecfbd16-c3ee-4ea0-4337-45682d534294/c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.dataHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:eecfbd16-c3ee-4ea0-4337-45682d534294/c2pa.assertions/c2pa.hash.data"
+ }
+ ],
+ "informational": [
+ {
+ "code": "signingCredential.ocsp.skipped",
+ "url": "self#jumbf=/c2pa/urn:c2pa:eecfbd16-c3ee-4ea0-4337-45682d534294/c2pa.signature"
+ },
+ {
+ "code": "assertion.dataHash.additionalExclusionsPresent",
+ "url": "self#jumbf=/c2pa/urn:c2pa:eecfbd16-c3ee-4ea0-4337-45682d534294/c2pa.assertions/c2pa.hash.data"
+ }
+ ],
+ "failure": []
+ }
+ },
+ "activeManifest": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:eecfbd16-c3ee-4ea0-4337-45682d534294",
+ "hash": "b64'bZqinbxvhgEUlckIKo+WujqWWh//1moGY1iCCAdQ1bw=",
+ "pad": "b64'"
+ },
+ "claimSignature": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:eecfbd16-c3ee-4ea0-4337-45682d534294/c2pa.signature",
+ "hash": "b64'6Xo+hlxnspzDe0IGVH2Zk7LmPHkpSpWS+1QvLrP68T8=",
+ "pad": "b64'"
+ }
+ }
+ },
+ "claim.v2": {
+ "instanceID": "f200bf63-2d3b-2ebd-524d-4634d42132ac",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:7781b3f0-c26c-f617-2565-4e8687c2b753/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA SDK for Android",
+ "version": "790468618:790840964"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'SdSVV2KWCssld25s1HaJd1alYQ4hICNuMR4Yl9icL50="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'ruOYYdoCKZI4INDLlrmDnGtja4ieuNsFTYt/wVQOPh8="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.data",
+ "hash": "b64'xRyKQcOttsGaxH++s12AWm0Z5lXkpURXCdOl7gK29uc="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "e62fe52c0b746e6b3722272b4b5e6309a31ac7",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Mobile A 1P ICA G3 L1"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "OU": "Google Photos Android",
+ "CN": "Google Photos"
+ },
+ "validity": {
+ "notBefore": "2025-08-05T18:21:27+00:00",
+ "notAfter": "2025-09-04T18:21:26+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2025-08-05T19:58:32+00:00",
+ "certificateInfo": {
+ "serialNumber": "801dd6ca0e3a76f622533f629162f7faa703c9",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Pixel Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google",
+ "CN": "Google Pixel Time Stamping Authority"
+ },
+ "validity": {
+ "notBefore": "2025-05-12T23:43:02+00:00",
+ "notAfter": "2032-11-11T08:43:01+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:7781b3f0-c26c-f617-2565-4e8687c2b753/c2pa.signature",
+ "explanation": "timestamp message digest matched: Google Pixel Time Stamping Authority"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:7781b3f0-c26c-f617-2565-4e8687c2b753/c2pa.signature",
+ "explanation": "timestamp cert trusted: Google Pixel Time Stamping Authority"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:7781b3f0-c26c-f617-2565-4e8687c2b753/c2pa.signature",
+ "explanation": "signing certificate trusted, found in System trust anchors"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:7781b3f0-c26c-f617-2565-4e8687c2b753/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:7781b3f0-c26c-f617-2565-4e8687c2b753/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:7781b3f0-c26c-f617-2565-4e8687c2b753/c2pa.assertions/c2pa.ingredient.v3",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:7781b3f0-c26c-f617-2565-4e8687c2b753/c2pa.assertions/c2pa.actions.v2",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:7781b3f0-c26c-f617-2565-4e8687c2b753/c2pa.assertions/c2pa.hash.data",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.hash.data"
+ },
+ {
+ "code": "assertion.dataHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:7781b3f0-c26c-f617-2565-4e8687c2b753/c2pa.assertions/c2pa.hash.data",
+ "explanation": "data hash valid"
+ }
+ ],
+ "informational": [],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-03-31T01:15:05.157+00:00"
+ },
+ "ingredientDeltas": [
+ {
+ "ingredientAssertionURI": "self#jumbf=/c2pa/urn:c2pa:7781b3f0-c26c-f617-2565-4e8687c2b753/c2pa.assertions/c2pa.ingredient.v3",
+ "validationDeltas": {
+ "success": [
+ {
+ "code": "ingredient.manifest.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:eecfbd16-c3ee-4ea0-4337-45682d534294",
+ "explanation": "ingredient hash matched"
+ }
+ ],
+ "informational": [],
+ "failure": []
+ }
+ }
+ ]
+ },
+ {
+ "label": "urn:c2pa:eecfbd16-c3ee-4ea0-4337-45682d534294",
+ "assertions": {
+ "c2pa.hash.data": {
+ "exclusions": [
+ {
+ "start": 6,
+ "length": 18044
+ },
+ {
+ "start": 18068,
+ "length": 5179
+ },
+ {
+ "start": 23865,
+ "length": 1159
+ },
+ {
+ "start": 25028,
+ "length": 65458
+ },
+ {
+ "start": 90490,
+ "length": 65458
+ },
+ {
+ "start": 155952,
+ "length": 65458
+ },
+ {
+ "start": 221414,
+ "length": 65458
+ },
+ {
+ "start": 286876,
+ "length": 65458
+ },
+ {
+ "start": 352338,
+ "length": 65458
+ },
+ {
+ "start": 417800,
+ "length": 65458
+ },
+ {
+ "start": 483262,
+ "length": 65458
+ },
+ {
+ "start": 548724,
+ "length": 65458
+ },
+ {
+ "start": 614186,
+ "length": 22222
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'AYgJaZosMByPAxgPMmQuZQvmDR40K1vS3coZ/ebDcZI=",
+ "pad": "b64'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+ "pad2": "b64'"
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.created",
+ "description": "Created by Pixel Camera.",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+ }
+ ]
+ }
+ },
+ "claim.v2": {
+ "instanceID": "ce00b058-a1ef-62f0-6bfd-4a942dcd0bd1",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:eecfbd16-c3ee-4ea0-4337-45682d534294/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA SDK for Android",
+ "version": "781252796:788932941"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.data",
+ "hash": "b64'c5W2clbb1LBbL/Mg3JvhCSyg6/QX039JudWWOU2Iyec="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'Axs3an5HFczmpjJiejh1Z5lKQi2MVSH8g1NVbjSNOjI="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "5855fdfb595875cbd50e958c611d3eecb78b1d",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Mobile A 1P ICA G3 L1"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Pixel Camera"
+ },
+ "validity": {
+ "notBefore": "2025-08-02T05:24:43+00:00",
+ "notAfter": "2025-09-01T05:24:42+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2025-08-05T19:42:03+00:00",
+ "certificateInfo": {
+ "serialNumber": "801dd6ca0e3a76f622533f629162f7faa703c9",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Pixel Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google",
+ "CN": "Google Pixel Time Stamping Authority"
+ },
+ "validity": {
+ "notBefore": "2025-05-12T23:43:02+00:00",
+ "notAfter": "2032-11-11T08:43:01+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [
+ {
+ "code": "ingredient.manifest.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:eecfbd16-c3ee-4ea0-4337-45682d534294",
+ "explanation": "ingredient hash matched"
+ }
+ ],
+ "informational": [],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-03-31T01:15:05.157+00:00"
+ }
+ }
+ ],
+ "jsonGenerator": {
+ "name": "c2pa-rs",
+ "version": "0.78.0"
+ },
+ "usedITL": false,
+ "usedTestCerts": false,
+ "_conformanceToolVersion": {
+ "commit": "d291fb884e02665d169a38928392c2710f6f0953",
+ "shortCommit": "d291fb8",
+ "date": "2026-03-19 20:11:48 -0400",
+ "branch": "HEAD",
+ "generatedAt": "2026-03-23T18:42:00.448Z"
+ }
+}
\ No newline at end of file
diff --git a/src/lib/rubrics/__fixtures__/capture-ai-and-non-ai-edits.signals.json b/src/lib/rubrics/__fixtures__/capture-ai-and-non-ai-edits.signals.json
new file mode 100644
index 0000000..50c7eb1
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/capture-ai-and-non-ai-edits.signals.json
@@ -0,0 +1,51 @@
+{
+ "rubricName": "C2PA Asset Signals Rubric (Local)",
+ "rubricVersion": "1.0.0",
+ "manifests": [
+ {
+ "assertedBy": {
+ "CN": "Google Photos",
+ "O": "Google LLC",
+ "OU": "Google Photos Android"
+ },
+ "mimeType": null,
+ "localInceptions": [],
+ "localTransformations": [
+ {
+ "trait": "transformation:signal_editorialAI",
+ "reportText": "Contains Editorial GenAI Transformations",
+ "multiple": false
+ },
+ {
+ "trait": "transformation:signal_editorialNonAI",
+ "reportText": "Contains Editorial Non-GenAI Transformations",
+ "multiple": false
+ }
+ ],
+ "allActionsIncluded": false,
+ "ingredients": [
+ {
+ "index": 1,
+ "relationship": "parentOf"
+ }
+ ]
+ },
+ {
+ "assertedBy": {
+ "CN": "Pixel Camera",
+ "O": "Google LLC"
+ },
+ "mimeType": null,
+ "localInceptions": [
+ {
+ "trait": "inception:signal_capturedMedia",
+ "reportText": "Contains Captured Media",
+ "multiple": false
+ }
+ ],
+ "localTransformations": [],
+ "allActionsIncluded": false,
+ "ingredients": []
+ }
+ ]
+}
diff --git a/src/lib/rubrics/__fixtures__/capture-crop.conformance.json b/src/lib/rubrics/__fixtures__/capture-crop.conformance.json
new file mode 100644
index 0000000..c17a9eb
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/capture-crop.conformance.json
@@ -0,0 +1,71 @@
+{
+ "rubricName": "C2PA Asset Conformance 0.1 Spec 2.2 Rubric",
+ "rubricVersion": "0.1.0",
+ "true": {
+ "validation": [
+ {
+ "trait": "active_manifest_urn",
+ "reportText": "Active manifest URN uses standard 'urn:c2pa:' prefix"
+ },
+ {
+ "trait": "inception_action_position",
+ "reportText": "Inception action is correctly positioned as the first item in the first created actions assertion"
+ },
+ {
+ "trait": "ingredient_relationship_values",
+ "reportText": "All ingredient assertions have valid relationship values"
+ },
+ {
+ "trait": "ingredient_v3_no_active_manifest",
+ "reportText": "Ingredient v3 assertions without activeManifest correctly omit validationResults"
+ },
+ {
+ "trait": "no_deprecated_actions",
+ "reportText": "No deprecated actions used"
+ },
+ {
+ "trait": "no_deprecated_assertions",
+ "reportText": "No deprecated standard assertions found"
+ },
+ {
+ "trait": "no_unsupported_assertions",
+ "reportText": "All standard assertions are supported for Spec 2.2"
+ },
+ {
+ "trait": "review_ratings_datasource",
+ "reportText": "No invalid co-occurrence of reviewRatings and human entry dataSource"
+ },
+ {
+ "trait": "trusted_data_present",
+ "reportText": "Validation results are present for trust analysis"
+ },
+ {
+ "trait": "trusted_success",
+ "reportText": "Asset is trusted"
+ },
+ {
+ "trait": "update_manifest_constraints",
+ "reportText": "Update manifest constraints are satisfied (or manifest is not an update manifest)"
+ },
+ {
+ "trait": "valid_data_present",
+ "reportText": "Validation results are present for integrity analysis"
+ },
+ {
+ "trait": "valid_success",
+ "reportText": "No validation mismatches found"
+ },
+ {
+ "trait": "well_formed_data_present",
+ "reportText": "Validation results are present for structural analysis"
+ },
+ {
+ "trait": "well_formed_success",
+ "reportText": "No structural failures found"
+ }
+ ]
+ },
+ "false": {
+ "validation": []
+ }
+}
diff --git a/src/lib/rubrics/__fixtures__/capture-crop.json b/src/lib/rubrics/__fixtures__/capture-crop.json
new file mode 100644
index 0000000..b56f574
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/capture-crop.json
@@ -0,0 +1,404 @@
+{
+ "@context": {
+ "@vocab": "https://contentcredentials.org/crjson",
+ "extras": "https://contentcredentials.org/crjson/extras"
+ },
+ "manifests": [
+ {
+ "label": "urn:c2pa:afc49daf-eb53-dade-4c73-4b76c548bb3d",
+ "assertions": {
+ "c2pa.hash.data": {
+ "exclusions": [
+ {
+ "start": 4763,
+ "length": 11890
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'8AoLcIe2wJMm5abCNn3Mmwh4nmwf4DOBKd5gfdIDBU4=",
+ "pad": "b64'AAAAAAAAAAAAAAAA"
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.opened",
+ "parameters": {
+ "ingredients": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'SdSVV2KWCssld25s1HaJd1alYQ4hICNuMR4Yl9icL50=",
+ "pad": "b64'"
+ }
+ ]
+ }
+ },
+ {
+ "action": "c2pa.cropped",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/humanEdits"
+ },
+ {
+ "action": "c2pa.enhanced",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/algorithmicallyEnhanced"
+ }
+ ]
+ },
+ "c2pa.ingredient.v3": {
+ "relationship": "parentOf",
+ "validationResults": {
+ "activeManifest": {
+ "success": [
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:eecfbd16-c3ee-4ea0-4337-45682d534294/c2pa.signature"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:eecfbd16-c3ee-4ea0-4337-45682d534294/c2pa.signature"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:eecfbd16-c3ee-4ea0-4337-45682d534294/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:eecfbd16-c3ee-4ea0-4337-45682d534294/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:eecfbd16-c3ee-4ea0-4337-45682d534294/c2pa.signature"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:eecfbd16-c3ee-4ea0-4337-45682d534294/c2pa.assertions/c2pa.hash.data"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:eecfbd16-c3ee-4ea0-4337-45682d534294/c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.dataHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:eecfbd16-c3ee-4ea0-4337-45682d534294/c2pa.assertions/c2pa.hash.data"
+ }
+ ],
+ "informational": [
+ {
+ "code": "signingCredential.ocsp.skipped",
+ "url": "self#jumbf=/c2pa/urn:c2pa:eecfbd16-c3ee-4ea0-4337-45682d534294/c2pa.signature"
+ },
+ {
+ "code": "assertion.dataHash.additionalExclusionsPresent",
+ "url": "self#jumbf=/c2pa/urn:c2pa:eecfbd16-c3ee-4ea0-4337-45682d534294/c2pa.assertions/c2pa.hash.data"
+ }
+ ],
+ "failure": []
+ }
+ },
+ "activeManifest": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:eecfbd16-c3ee-4ea0-4337-45682d534294",
+ "hash": "b64'bZqinbxvhgEUlckIKo+WujqWWh//1moGY1iCCAdQ1bw=",
+ "pad": "b64'"
+ },
+ "claimSignature": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:eecfbd16-c3ee-4ea0-4337-45682d534294/c2pa.signature",
+ "hash": "b64'6Xo+hlxnspzDe0IGVH2Zk7LmPHkpSpWS+1QvLrP68T8=",
+ "pad": "b64'"
+ }
+ }
+ },
+ "claim.v2": {
+ "instanceID": "9ba79de5-f1f7-d817-8734-4b7e798b7a29",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:afc49daf-eb53-dade-4c73-4b76c548bb3d/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA SDK for Android",
+ "version": "790468618:790840964"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'SdSVV2KWCssld25s1HaJd1alYQ4hICNuMR4Yl9icL50="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'ZtyUPh7ZeuzRR7YSiLoiOIo+cgdVT07zweDvYrzRSKg="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.data",
+ "hash": "b64'8G3Hf7mlkfTX9qcxqairnSL9D1urbTxXbTkyPt/hcEQ="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "d30a76ecceb8263ec1de469b2a4239440e1bfd",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Mobile A 1P ICA G3 L1"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "OU": "Google Photos Android",
+ "CN": "Google Photos"
+ },
+ "validity": {
+ "notBefore": "2025-08-05T18:21:27+00:00",
+ "notAfter": "2025-09-04T18:21:26+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2025-08-05T19:55:33+00:00",
+ "certificateInfo": {
+ "serialNumber": "801dd6ca0e3a76f622533f629162f7faa703c9",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Pixel Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google",
+ "CN": "Google Pixel Time Stamping Authority"
+ },
+ "validity": {
+ "notBefore": "2025-05-12T23:43:02+00:00",
+ "notAfter": "2032-11-11T08:43:01+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:afc49daf-eb53-dade-4c73-4b76c548bb3d/c2pa.signature",
+ "explanation": "timestamp message digest matched: Google Pixel Time Stamping Authority"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:afc49daf-eb53-dade-4c73-4b76c548bb3d/c2pa.signature",
+ "explanation": "timestamp cert trusted: Google Pixel Time Stamping Authority"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:afc49daf-eb53-dade-4c73-4b76c548bb3d/c2pa.signature",
+ "explanation": "signing certificate trusted, found in System trust anchors"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:afc49daf-eb53-dade-4c73-4b76c548bb3d/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:afc49daf-eb53-dade-4c73-4b76c548bb3d/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:afc49daf-eb53-dade-4c73-4b76c548bb3d/c2pa.assertions/c2pa.ingredient.v3",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:afc49daf-eb53-dade-4c73-4b76c548bb3d/c2pa.assertions/c2pa.actions.v2",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:afc49daf-eb53-dade-4c73-4b76c548bb3d/c2pa.assertions/c2pa.hash.data",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.hash.data"
+ },
+ {
+ "code": "assertion.dataHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:afc49daf-eb53-dade-4c73-4b76c548bb3d/c2pa.assertions/c2pa.hash.data",
+ "explanation": "data hash valid"
+ }
+ ],
+ "informational": [],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-03-31T03:48:58.674+00:00"
+ },
+ "ingredientDeltas": [
+ {
+ "ingredientAssertionURI": "self#jumbf=/c2pa/urn:c2pa:afc49daf-eb53-dade-4c73-4b76c548bb3d/c2pa.assertions/c2pa.ingredient.v3",
+ "validationDeltas": {
+ "success": [
+ {
+ "code": "ingredient.manifest.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:eecfbd16-c3ee-4ea0-4337-45682d534294",
+ "explanation": "ingredient hash matched"
+ }
+ ],
+ "informational": [],
+ "failure": []
+ }
+ }
+ ]
+ },
+ {
+ "label": "urn:c2pa:eecfbd16-c3ee-4ea0-4337-45682d534294",
+ "assertions": {
+ "c2pa.hash.data": {
+ "exclusions": [
+ {
+ "start": 6,
+ "length": 18044
+ },
+ {
+ "start": 18068,
+ "length": 5179
+ },
+ {
+ "start": 23865,
+ "length": 1159
+ },
+ {
+ "start": 25028,
+ "length": 65458
+ },
+ {
+ "start": 90490,
+ "length": 65458
+ },
+ {
+ "start": 155952,
+ "length": 65458
+ },
+ {
+ "start": 221414,
+ "length": 65458
+ },
+ {
+ "start": 286876,
+ "length": 65458
+ },
+ {
+ "start": 352338,
+ "length": 65458
+ },
+ {
+ "start": 417800,
+ "length": 65458
+ },
+ {
+ "start": 483262,
+ "length": 65458
+ },
+ {
+ "start": 548724,
+ "length": 65458
+ },
+ {
+ "start": 614186,
+ "length": 22222
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'AYgJaZosMByPAxgPMmQuZQvmDR40K1vS3coZ/ebDcZI=",
+ "pad": "b64'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+ "pad2": "b64'"
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.created",
+ "description": "Created by Pixel Camera.",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+ }
+ ]
+ }
+ },
+ "claim.v2": {
+ "instanceID": "ce00b058-a1ef-62f0-6bfd-4a942dcd0bd1",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:eecfbd16-c3ee-4ea0-4337-45682d534294/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA SDK for Android",
+ "version": "781252796:788932941"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.data",
+ "hash": "b64'c5W2clbb1LBbL/Mg3JvhCSyg6/QX039JudWWOU2Iyec="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'Axs3an5HFczmpjJiejh1Z5lKQi2MVSH8g1NVbjSNOjI="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "5855fdfb595875cbd50e958c611d3eecb78b1d",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Mobile A 1P ICA G3 L1"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Pixel Camera"
+ },
+ "validity": {
+ "notBefore": "2025-08-02T05:24:43+00:00",
+ "notAfter": "2025-09-01T05:24:42+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2025-08-05T19:42:03+00:00",
+ "certificateInfo": {
+ "serialNumber": "801dd6ca0e3a76f622533f629162f7faa703c9",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Pixel Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google",
+ "CN": "Google Pixel Time Stamping Authority"
+ },
+ "validity": {
+ "notBefore": "2025-05-12T23:43:02+00:00",
+ "notAfter": "2032-11-11T08:43:01+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [
+ {
+ "code": "ingredient.manifest.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:eecfbd16-c3ee-4ea0-4337-45682d534294",
+ "explanation": "ingredient hash matched"
+ }
+ ],
+ "informational": [],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-03-31T03:48:58.674+00:00"
+ }
+ }
+ ],
+ "jsonGenerator": {
+ "name": "c2pa-rs",
+ "version": "0.78.0"
+ },
+ "usedITL": false,
+ "usedTestCerts": false,
+ "_conformanceToolVersion": {
+ "commit": "d291fb884e02665d169a38928392c2710f6f0953",
+ "shortCommit": "d291fb8",
+ "date": "2026-03-19 20:11:48 -0400",
+ "branch": "HEAD",
+ "generatedAt": "2026-03-23T18:42:00.448Z"
+ }
+}
\ No newline at end of file
diff --git a/src/lib/rubrics/__fixtures__/capture-crop.signals.json b/src/lib/rubrics/__fixtures__/capture-crop.signals.json
new file mode 100644
index 0000000..6c0aa11
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/capture-crop.signals.json
@@ -0,0 +1,51 @@
+{
+ "rubricName": "C2PA Asset Signals Rubric (Local)",
+ "rubricVersion": "1.0.0",
+ "manifests": [
+ {
+ "assertedBy": {
+ "CN": "Google Photos",
+ "O": "Google LLC",
+ "OU": "Google Photos Android"
+ },
+ "mimeType": null,
+ "localInceptions": [],
+ "localTransformations": [
+ {
+ "trait": "transformation:signal_editorialNonAI",
+ "reportText": "Contains Editorial Non-GenAI Transformations",
+ "multiple": false
+ },
+ {
+ "trait": "transformation:signal_nonEditorial",
+ "reportText": "Contains Non-editorial Transformations",
+ "multiple": false
+ }
+ ],
+ "allActionsIncluded": false,
+ "ingredients": [
+ {
+ "index": 1,
+ "relationship": "parentOf"
+ }
+ ]
+ },
+ {
+ "assertedBy": {
+ "CN": "Pixel Camera",
+ "O": "Google LLC"
+ },
+ "mimeType": null,
+ "localInceptions": [
+ {
+ "trait": "inception:signal_capturedMedia",
+ "reportText": "Contains Captured Media",
+ "multiple": false
+ }
+ ],
+ "localTransformations": [],
+ "allActionsIncluded": false,
+ "ingredients": []
+ }
+ ]
+}
diff --git a/src/lib/rubrics/__fixtures__/capture-non-ai-then-ai-edits.conformance.json b/src/lib/rubrics/__fixtures__/capture-non-ai-then-ai-edits.conformance.json
new file mode 100644
index 0000000..c17a9eb
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/capture-non-ai-then-ai-edits.conformance.json
@@ -0,0 +1,71 @@
+{
+ "rubricName": "C2PA Asset Conformance 0.1 Spec 2.2 Rubric",
+ "rubricVersion": "0.1.0",
+ "true": {
+ "validation": [
+ {
+ "trait": "active_manifest_urn",
+ "reportText": "Active manifest URN uses standard 'urn:c2pa:' prefix"
+ },
+ {
+ "trait": "inception_action_position",
+ "reportText": "Inception action is correctly positioned as the first item in the first created actions assertion"
+ },
+ {
+ "trait": "ingredient_relationship_values",
+ "reportText": "All ingredient assertions have valid relationship values"
+ },
+ {
+ "trait": "ingredient_v3_no_active_manifest",
+ "reportText": "Ingredient v3 assertions without activeManifest correctly omit validationResults"
+ },
+ {
+ "trait": "no_deprecated_actions",
+ "reportText": "No deprecated actions used"
+ },
+ {
+ "trait": "no_deprecated_assertions",
+ "reportText": "No deprecated standard assertions found"
+ },
+ {
+ "trait": "no_unsupported_assertions",
+ "reportText": "All standard assertions are supported for Spec 2.2"
+ },
+ {
+ "trait": "review_ratings_datasource",
+ "reportText": "No invalid co-occurrence of reviewRatings and human entry dataSource"
+ },
+ {
+ "trait": "trusted_data_present",
+ "reportText": "Validation results are present for trust analysis"
+ },
+ {
+ "trait": "trusted_success",
+ "reportText": "Asset is trusted"
+ },
+ {
+ "trait": "update_manifest_constraints",
+ "reportText": "Update manifest constraints are satisfied (or manifest is not an update manifest)"
+ },
+ {
+ "trait": "valid_data_present",
+ "reportText": "Validation results are present for integrity analysis"
+ },
+ {
+ "trait": "valid_success",
+ "reportText": "No validation mismatches found"
+ },
+ {
+ "trait": "well_formed_data_present",
+ "reportText": "Validation results are present for structural analysis"
+ },
+ {
+ "trait": "well_formed_success",
+ "reportText": "No structural failures found"
+ }
+ ]
+ },
+ "false": {
+ "validation": []
+ }
+}
diff --git a/src/lib/rubrics/__fixtures__/capture-non-ai-then-ai-edits.json b/src/lib/rubrics/__fixtures__/capture-non-ai-then-ai-edits.json
new file mode 100644
index 0000000..acd38c7
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/capture-non-ai-then-ai-edits.json
@@ -0,0 +1,606 @@
+{
+ "@context": {
+ "@vocab": "https://contentcredentials.org/crjson",
+ "extras": "https://contentcredentials.org/crjson/extras"
+ },
+ "manifests": [
+ {
+ "label": "urn:c2pa:b91b82f2-8770-7ef0-5e2e-41c2b57a6481",
+ "assertions": {
+ "c2pa.hash.data": {
+ "exclusions": [
+ {
+ "start": 4764,
+ "length": 18727
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'oNGOk8dfwKamd2IWwKZn9FH0YZba1XAIGUz59OYeQ6A=",
+ "pad": "b64'AAAAAAAAAAAAAAAA"
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.opened",
+ "parameters": {
+ "ingredients": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'YiYcZd+YmoiNmylw8GnsmqRBft5O4ZGLjFbTSG5s/Qw=",
+ "pad": "b64'"
+ }
+ ]
+ }
+ },
+ {
+ "action": "c2pa.edited",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia"
+ }
+ ]
+ },
+ "c2pa.ingredient.v3": {
+ "relationship": "parentOf",
+ "validationResults": {
+ "activeManifest": {
+ "success": [
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:8edfb78e-661f-394a-f1b5-4c9946740ffc/c2pa.signature"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:8edfb78e-661f-394a-f1b5-4c9946740ffc/c2pa.signature"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:8edfb78e-661f-394a-f1b5-4c9946740ffc/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:8edfb78e-661f-394a-f1b5-4c9946740ffc/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:8edfb78e-661f-394a-f1b5-4c9946740ffc/c2pa.signature"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:8edfb78e-661f-394a-f1b5-4c9946740ffc/c2pa.assertions/c2pa.ingredient.v3"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:8edfb78e-661f-394a-f1b5-4c9946740ffc/c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:8edfb78e-661f-394a-f1b5-4c9946740ffc/c2pa.assertions/c2pa.hash.data"
+ },
+ {
+ "code": "ingredient.claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:8edfb78e-661f-394a-f1b5-4c9946740ffc/c2pa.assertions/c2pa.ingredient.v3"
+ },
+ {
+ "code": "assertion.dataHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:8edfb78e-661f-394a-f1b5-4c9946740ffc/c2pa.assertions/c2pa.hash.data"
+ }
+ ],
+ "informational": [
+ {
+ "code": "signingCredential.ocsp.skipped",
+ "url": "self#jumbf=/c2pa/urn:c2pa:8edfb78e-661f-394a-f1b5-4c9946740ffc/c2pa.signature"
+ }
+ ],
+ "failure": []
+ },
+ "ingredientDeltas": [
+ {
+ "ingredientAssertionURI": "self#jumbf=/c2pa/urn:c2pa:8edfb78e-661f-394a-f1b5-4c9946740ffc/c2pa.assertions/c2pa.ingredient.v3",
+ "validationDeltas": {
+ "success": [],
+ "informational": [],
+ "failure": []
+ }
+ }
+ ]
+ },
+ "activeManifest": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:8edfb78e-661f-394a-f1b5-4c9946740ffc",
+ "hash": "b64'U9oBBbmxrZ85LJBetk2ZcvOnDs6FcKnv/9i7JzdlZFE=",
+ "pad": "b64'"
+ },
+ "claimSignature": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:8edfb78e-661f-394a-f1b5-4c9946740ffc/c2pa.signature",
+ "hash": "b64'fHh3sITKpfJNECRlQ01825Dw1FblPF/A5ezOwUV+J/k=",
+ "pad": "b64'"
+ }
+ }
+ },
+ "claim.v2": {
+ "instanceID": "21df8610-a42a-4191-bdfb-4ed314c77b98",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:b91b82f2-8770-7ef0-5e2e-41c2b57a6481/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA SDK for Android",
+ "version": "790468618:790840964"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'YiYcZd+YmoiNmylw8GnsmqRBft5O4ZGLjFbTSG5s/Qw="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'XobXR4QL3mEKoLL5uiPqLqwekt0Ao6uaC670WrMPW8U="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.data",
+ "hash": "b64'sfZg6Om6IvtSYHHBqxH0E9RtyFgqH1t0hUtwlcvTalk="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "f7fee32e70be2f7ad7d966b403100df64bba6",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Mobile A 1P ICA G3 L1"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "OU": "Google Photos Android",
+ "CN": "Google Photos"
+ },
+ "validity": {
+ "notBefore": "2025-08-05T18:21:27+00:00",
+ "notAfter": "2025-09-04T18:21:26+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2025-08-07T21:05:49+00:00",
+ "certificateInfo": {
+ "serialNumber": "801dd6ca0e3a76f622533f629162f7faa703c9",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Pixel Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google",
+ "CN": "Google Pixel Time Stamping Authority"
+ },
+ "validity": {
+ "notBefore": "2025-05-12T23:43:02+00:00",
+ "notAfter": "2032-11-11T08:43:01+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b91b82f2-8770-7ef0-5e2e-41c2b57a6481/c2pa.signature",
+ "explanation": "timestamp message digest matched: Google Pixel Time Stamping Authority"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b91b82f2-8770-7ef0-5e2e-41c2b57a6481/c2pa.signature",
+ "explanation": "timestamp cert trusted: Google Pixel Time Stamping Authority"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b91b82f2-8770-7ef0-5e2e-41c2b57a6481/c2pa.signature",
+ "explanation": "signing certificate trusted, found in System trust anchors"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b91b82f2-8770-7ef0-5e2e-41c2b57a6481/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b91b82f2-8770-7ef0-5e2e-41c2b57a6481/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b91b82f2-8770-7ef0-5e2e-41c2b57a6481/c2pa.assertions/c2pa.ingredient.v3",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b91b82f2-8770-7ef0-5e2e-41c2b57a6481/c2pa.assertions/c2pa.actions.v2",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b91b82f2-8770-7ef0-5e2e-41c2b57a6481/c2pa.assertions/c2pa.hash.data",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.hash.data"
+ },
+ {
+ "code": "assertion.dataHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b91b82f2-8770-7ef0-5e2e-41c2b57a6481/c2pa.assertions/c2pa.hash.data",
+ "explanation": "data hash valid"
+ }
+ ],
+ "informational": [],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-03-31T10:18:21.746+00:00"
+ },
+ "ingredientDeltas": [
+ {
+ "ingredientAssertionURI": "self#jumbf=/c2pa/urn:c2pa:b91b82f2-8770-7ef0-5e2e-41c2b57a6481/c2pa.assertions/c2pa.ingredient.v3",
+ "validationDeltas": {
+ "success": [
+ {
+ "code": "ingredient.manifest.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:8edfb78e-661f-394a-f1b5-4c9946740ffc",
+ "explanation": "ingredient hash matched"
+ }
+ ],
+ "informational": [],
+ "failure": []
+ }
+ }
+ ]
+ },
+ {
+ "label": "urn:c2pa:8edfb78e-661f-394a-f1b5-4c9946740ffc",
+ "assertions": {
+ "c2pa.hash.data": {
+ "exclusions": [
+ {
+ "start": 4764,
+ "length": 11782
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'BrE3f7TufDAldkLNEVM8NMZGwbxup4DYECz8WGjGqdo=",
+ "pad": "b64'AAAAAAAAAAAAAAAA"
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.opened",
+ "parameters": {
+ "ingredients": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'poiOA+IbJAQzIUeIRVgqGtxhhJ9uDjIE0RTfNgLoafc=",
+ "pad": "b64'"
+ }
+ ]
+ }
+ },
+ {
+ "action": "c2pa.cropped",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/humanEdits"
+ }
+ ]
+ },
+ "c2pa.ingredient.v3": {
+ "relationship": "parentOf",
+ "validationResults": {
+ "activeManifest": {
+ "success": [
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:f641bc5e-19fb-46c8-6b3c-4cbb47df508e/c2pa.signature"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:f641bc5e-19fb-46c8-6b3c-4cbb47df508e/c2pa.signature"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:f641bc5e-19fb-46c8-6b3c-4cbb47df508e/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:f641bc5e-19fb-46c8-6b3c-4cbb47df508e/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:f641bc5e-19fb-46c8-6b3c-4cbb47df508e/c2pa.signature"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:f641bc5e-19fb-46c8-6b3c-4cbb47df508e/c2pa.assertions/c2pa.hash.data"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:f641bc5e-19fb-46c8-6b3c-4cbb47df508e/c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.dataHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:f641bc5e-19fb-46c8-6b3c-4cbb47df508e/c2pa.assertions/c2pa.hash.data"
+ }
+ ],
+ "informational": [
+ {
+ "code": "signingCredential.ocsp.skipped",
+ "url": "self#jumbf=/c2pa/urn:c2pa:f641bc5e-19fb-46c8-6b3c-4cbb47df508e/c2pa.signature"
+ },
+ {
+ "code": "assertion.dataHash.additionalExclusionsPresent",
+ "url": "self#jumbf=/c2pa/urn:c2pa:f641bc5e-19fb-46c8-6b3c-4cbb47df508e/c2pa.assertions/c2pa.hash.data"
+ }
+ ],
+ "failure": []
+ }
+ },
+ "activeManifest": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:f641bc5e-19fb-46c8-6b3c-4cbb47df508e",
+ "hash": "b64'9PL+68QiTQYM6s6Dq3WDnc036VdZhq6rinGTaNi/aCc=",
+ "pad": "b64'"
+ },
+ "claimSignature": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:f641bc5e-19fb-46c8-6b3c-4cbb47df508e/c2pa.signature",
+ "hash": "b64'ikee1NXy21wArj5ZkPB1wX7Q6b0bcwksPyS+hPVGbaU=",
+ "pad": "b64'"
+ }
+ }
+ },
+ "claim.v2": {
+ "instanceID": "a85fa325-d9b8-cb54-0e4a-478878fe8318",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:8edfb78e-661f-394a-f1b5-4c9946740ffc/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA SDK for Android",
+ "version": "790468618:790840964"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'poiOA+IbJAQzIUeIRVgqGtxhhJ9uDjIE0RTfNgLoafc="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'/Us7UGaaQyLBUT/PEaw94tGctHVe/KidoHuVtox2Wik="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.data",
+ "hash": "b64'rWL428S6sS+6F/nuu6KPx7of0Y1/HasU7RfdBn1dGOM="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "e8c55ffdb991d98b248d886135ad5ee225e75c",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Mobile A 1P ICA G3 L1"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "OU": "Google Photos Android",
+ "CN": "Google Photos"
+ },
+ "validity": {
+ "notBefore": "2025-08-05T18:21:27+00:00",
+ "notAfter": "2025-09-04T18:21:26+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2025-08-07T21:04:09+00:00",
+ "certificateInfo": {
+ "serialNumber": "801dd6ca0e3a76f622533f629162f7faa703c9",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Pixel Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google",
+ "CN": "Google Pixel Time Stamping Authority"
+ },
+ "validity": {
+ "notBefore": "2025-05-12T23:43:02+00:00",
+ "notAfter": "2032-11-11T08:43:01+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [
+ {
+ "code": "ingredient.manifest.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:8edfb78e-661f-394a-f1b5-4c9946740ffc",
+ "explanation": "ingredient hash matched"
+ }
+ ],
+ "informational": [],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-03-31T10:18:21.746+00:00"
+ },
+ "ingredientDeltas": [
+ {
+ "ingredientAssertionURI": "self#jumbf=/c2pa/urn:c2pa:8edfb78e-661f-394a-f1b5-4c9946740ffc/c2pa.assertions/c2pa.ingredient.v3",
+ "validationDeltas": {
+ "success": [
+ {
+ "code": "ingredient.manifest.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:f641bc5e-19fb-46c8-6b3c-4cbb47df508e",
+ "explanation": "ingredient hash matched"
+ }
+ ],
+ "informational": [],
+ "failure": []
+ }
+ }
+ ]
+ },
+ {
+ "label": "urn:c2pa:f641bc5e-19fb-46c8-6b3c-4cbb47df508e",
+ "assertions": {
+ "c2pa.hash.data": {
+ "exclusions": [
+ {
+ "start": 6,
+ "length": 30632
+ },
+ {
+ "start": 30656,
+ "length": 5181
+ },
+ {
+ "start": 36455,
+ "length": 1158
+ },
+ {
+ "start": 37617,
+ "length": 65458
+ },
+ {
+ "start": 103079,
+ "length": 65458
+ },
+ {
+ "start": 168541,
+ "length": 65458
+ },
+ {
+ "start": 234003,
+ "length": 65458
+ },
+ {
+ "start": 299465,
+ "length": 65458
+ },
+ {
+ "start": 364927,
+ "length": 65458
+ },
+ {
+ "start": 430389,
+ "length": 65458
+ },
+ {
+ "start": 495851,
+ "length": 65458
+ },
+ {
+ "start": 561313,
+ "length": 65458
+ },
+ {
+ "start": 626775,
+ "length": 12366
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'y6W4UFXTv4UdEoy/SKFl4A2kVVVWqkZPSvy7WQLPUfE=",
+ "pad": "b64'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+ "pad2": "b64'"
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.created",
+ "description": "Created by Pixel Camera.",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+ }
+ ]
+ }
+ },
+ "claim.v2": {
+ "instanceID": "d0afb3cd-f150-50d1-29f2-408bb8c51c4c",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:f641bc5e-19fb-46c8-6b3c-4cbb47df508e/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA SDK for Android",
+ "version": "781252796:788932941"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.data",
+ "hash": "b64'RYoCa7MQHz0xtftdEs4I27BLylOM3aGPWlB6g8mBoM4="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'Axs3an5HFczmpjJiejh1Z5lKQi2MVSH8g1NVbjSNOjI="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "b6d014af6648c5633e710fa5cd80549b6d9f8c",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Mobile A 1P ICA G3 L1"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Pixel Camera"
+ },
+ "validity": {
+ "notBefore": "2025-08-02T05:25:09+00:00",
+ "notAfter": "2025-09-01T05:25:08+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2025-08-07T21:00:08+00:00",
+ "certificateInfo": {
+ "serialNumber": "801dd6ca0e3a76f622533f629162f7faa703c9",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Pixel Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google",
+ "CN": "Google Pixel Time Stamping Authority"
+ },
+ "validity": {
+ "notBefore": "2025-05-12T23:43:02+00:00",
+ "notAfter": "2032-11-11T08:43:01+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [
+ {
+ "code": "ingredient.manifest.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:f641bc5e-19fb-46c8-6b3c-4cbb47df508e",
+ "explanation": "ingredient hash matched"
+ }
+ ],
+ "informational": [],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-03-31T10:18:21.746+00:00"
+ }
+ }
+ ],
+ "jsonGenerator": {
+ "name": "c2pa-rs",
+ "version": "0.78.0"
+ },
+ "usedITL": false,
+ "usedTestCerts": false,
+ "_conformanceToolVersion": {
+ "commit": "d291fb884e02665d169a38928392c2710f6f0953",
+ "shortCommit": "d291fb8",
+ "date": "2026-03-19 20:11:48 -0400",
+ "branch": "HEAD",
+ "generatedAt": "2026-03-23T18:42:00.448Z"
+ }
+}
\ No newline at end of file
diff --git a/src/lib/rubrics/__fixtures__/capture-non-ai-then-ai-edits.signals.json b/src/lib/rubrics/__fixtures__/capture-non-ai-then-ai-edits.signals.json
new file mode 100644
index 0000000..4b2ec4b
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/capture-non-ai-then-ai-edits.signals.json
@@ -0,0 +1,69 @@
+{
+ "rubricName": "C2PA Asset Signals Rubric (Local)",
+ "rubricVersion": "1.0.0",
+ "manifests": [
+ {
+ "assertedBy": {
+ "CN": "Google Photos",
+ "O": "Google LLC",
+ "OU": "Google Photos Android"
+ },
+ "mimeType": null,
+ "localInceptions": [],
+ "localTransformations": [
+ {
+ "trait": "transformation:signal_editorialAI",
+ "reportText": "Contains Editorial GenAI Transformations",
+ "multiple": false
+ }
+ ],
+ "allActionsIncluded": false,
+ "ingredients": [
+ {
+ "index": 1,
+ "relationship": "parentOf"
+ }
+ ]
+ },
+ {
+ "assertedBy": {
+ "CN": "Google Photos",
+ "O": "Google LLC",
+ "OU": "Google Photos Android"
+ },
+ "mimeType": null,
+ "localInceptions": [],
+ "localTransformations": [
+ {
+ "trait": "transformation:signal_editorialNonAI",
+ "reportText": "Contains Editorial Non-GenAI Transformations",
+ "multiple": false
+ }
+ ],
+ "allActionsIncluded": false,
+ "ingredients": [
+ {
+ "index": 2,
+ "relationship": "parentOf"
+ }
+ ]
+ },
+ {
+ "assertedBy": {
+ "CN": "Pixel Camera",
+ "O": "Google LLC"
+ },
+ "mimeType": null,
+ "localInceptions": [
+ {
+ "trait": "inception:signal_capturedMedia",
+ "reportText": "Contains Captured Media",
+ "multiple": false
+ }
+ ],
+ "localTransformations": [],
+ "allActionsIncluded": false,
+ "ingredients": []
+ }
+ ]
+}
diff --git a/src/lib/rubrics/__fixtures__/capture-panorama.conformance.json b/src/lib/rubrics/__fixtures__/capture-panorama.conformance.json
new file mode 100644
index 0000000..c17a9eb
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/capture-panorama.conformance.json
@@ -0,0 +1,71 @@
+{
+ "rubricName": "C2PA Asset Conformance 0.1 Spec 2.2 Rubric",
+ "rubricVersion": "0.1.0",
+ "true": {
+ "validation": [
+ {
+ "trait": "active_manifest_urn",
+ "reportText": "Active manifest URN uses standard 'urn:c2pa:' prefix"
+ },
+ {
+ "trait": "inception_action_position",
+ "reportText": "Inception action is correctly positioned as the first item in the first created actions assertion"
+ },
+ {
+ "trait": "ingredient_relationship_values",
+ "reportText": "All ingredient assertions have valid relationship values"
+ },
+ {
+ "trait": "ingredient_v3_no_active_manifest",
+ "reportText": "Ingredient v3 assertions without activeManifest correctly omit validationResults"
+ },
+ {
+ "trait": "no_deprecated_actions",
+ "reportText": "No deprecated actions used"
+ },
+ {
+ "trait": "no_deprecated_assertions",
+ "reportText": "No deprecated standard assertions found"
+ },
+ {
+ "trait": "no_unsupported_assertions",
+ "reportText": "All standard assertions are supported for Spec 2.2"
+ },
+ {
+ "trait": "review_ratings_datasource",
+ "reportText": "No invalid co-occurrence of reviewRatings and human entry dataSource"
+ },
+ {
+ "trait": "trusted_data_present",
+ "reportText": "Validation results are present for trust analysis"
+ },
+ {
+ "trait": "trusted_success",
+ "reportText": "Asset is trusted"
+ },
+ {
+ "trait": "update_manifest_constraints",
+ "reportText": "Update manifest constraints are satisfied (or manifest is not an update manifest)"
+ },
+ {
+ "trait": "valid_data_present",
+ "reportText": "Validation results are present for integrity analysis"
+ },
+ {
+ "trait": "valid_success",
+ "reportText": "No validation mismatches found"
+ },
+ {
+ "trait": "well_formed_data_present",
+ "reportText": "Validation results are present for structural analysis"
+ },
+ {
+ "trait": "well_formed_success",
+ "reportText": "No structural failures found"
+ }
+ ]
+ },
+ "false": {
+ "validation": []
+ }
+}
diff --git a/src/lib/rubrics/__fixtures__/capture-panorama.json b/src/lib/rubrics/__fixtures__/capture-panorama.json
new file mode 100644
index 0000000..c2ece47
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/capture-panorama.json
@@ -0,0 +1,461 @@
+{
+ "@context": {
+ "@vocab": "https://contentcredentials.org/crjson",
+ "extras": "https://contentcredentials.org/crjson/extras"
+ },
+ "manifests": [
+ {
+ "label": "urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255",
+ "assertions": {
+ "c2pa.hash.multi-asset": {
+ "parts": [
+ {
+ "location": {
+ "byteOffset": 0,
+ "length": 4920752
+ },
+ "hashAssertion": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.assertions/c2pa.hash.data.part",
+ "alg": "",
+ "hash": "b64'y0kpFXeDy86udKXF+kj3j8Gc7uRjUfLeOn3T1IjMeik=",
+ "pad": "b64'"
+ },
+ "optional": false
+ },
+ {
+ "location": {
+ "byteOffset": 4920752,
+ "length": 83067
+ },
+ "hashAssertion": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.assertions/c2pa.hash.data.part__1",
+ "alg": "",
+ "hash": "b64'9hj61bEFiY955OMGx0bMnHus9uo+OXPYumvu9SlgPqs=",
+ "pad": "b64'"
+ },
+ "optional": true
+ }
+ ],
+ "pad": "b64'AAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+ "pad2": "b64'AA=="
+ },
+ "c2pa.hash.data.part__1": {
+ "alg": "sha256",
+ "hash": "b64'q1SZYPsEms/e0tD3k3XGgBgFqPy7O5VVEVDZRCYeHuQ=",
+ "pad": "b64'"
+ },
+ "c2pa.hash.data.part": {
+ "exclusions": [
+ {
+ "start": 6,
+ "length": 1019
+ },
+ {
+ "start": 1043,
+ "length": 7840
+ },
+ {
+ "start": 9501,
+ "length": 1063
+ },
+ {
+ "start": 10568,
+ "length": 65458
+ },
+ {
+ "start": 76030,
+ "length": 65458
+ },
+ {
+ "start": 141492,
+ "length": 65458
+ },
+ {
+ "start": 206954,
+ "length": 65458
+ },
+ {
+ "start": 272416,
+ "length": 65458
+ },
+ {
+ "start": 337878,
+ "length": 65458
+ },
+ {
+ "start": 403340,
+ "length": 65458
+ },
+ {
+ "start": 468802,
+ "length": 65458
+ },
+ {
+ "start": 534264,
+ "length": 65458
+ },
+ {
+ "start": 599726,
+ "length": 65458
+ },
+ {
+ "start": 665188,
+ "length": 65458
+ },
+ {
+ "start": 730650,
+ "length": 65458
+ },
+ {
+ "start": 796112,
+ "length": 65458
+ },
+ {
+ "start": 861574,
+ "length": 65458
+ },
+ {
+ "start": 927036,
+ "length": 65458
+ },
+ {
+ "start": 992498,
+ "length": 65458
+ },
+ {
+ "start": 1057960,
+ "length": 65458
+ },
+ {
+ "start": 1123422,
+ "length": 65458
+ },
+ {
+ "start": 1188884,
+ "length": 65458
+ },
+ {
+ "start": 1254346,
+ "length": 65458
+ },
+ {
+ "start": 1319808,
+ "length": 65458
+ },
+ {
+ "start": 1385270,
+ "length": 65458
+ },
+ {
+ "start": 1450732,
+ "length": 65458
+ },
+ {
+ "start": 1516194,
+ "length": 65458
+ },
+ {
+ "start": 1581656,
+ "length": 65458
+ },
+ {
+ "start": 1647118,
+ "length": 37405
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'caEich5nUouL2TIbXXRBm71EEizfj/QpXjRB8YvtN6o=",
+ "pad": "b64'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+ "pad2": "b64'"
+ },
+ "c2pa.hash.data": {
+ "exclusions": [
+ {
+ "start": 6,
+ "length": 1019
+ },
+ {
+ "start": 1043,
+ "length": 7840
+ },
+ {
+ "start": 9501,
+ "length": 1063
+ },
+ {
+ "start": 10568,
+ "length": 65458
+ },
+ {
+ "start": 76030,
+ "length": 65458
+ },
+ {
+ "start": 141492,
+ "length": 65458
+ },
+ {
+ "start": 206954,
+ "length": 65458
+ },
+ {
+ "start": 272416,
+ "length": 65458
+ },
+ {
+ "start": 337878,
+ "length": 65458
+ },
+ {
+ "start": 403340,
+ "length": 65458
+ },
+ {
+ "start": 468802,
+ "length": 65458
+ },
+ {
+ "start": 534264,
+ "length": 65458
+ },
+ {
+ "start": 599726,
+ "length": 65458
+ },
+ {
+ "start": 665188,
+ "length": 65458
+ },
+ {
+ "start": 730650,
+ "length": 65458
+ },
+ {
+ "start": 796112,
+ "length": 65458
+ },
+ {
+ "start": 861574,
+ "length": 65458
+ },
+ {
+ "start": 927036,
+ "length": 65458
+ },
+ {
+ "start": 992498,
+ "length": 65458
+ },
+ {
+ "start": 1057960,
+ "length": 65458
+ },
+ {
+ "start": 1123422,
+ "length": 65458
+ },
+ {
+ "start": 1188884,
+ "length": 65458
+ },
+ {
+ "start": 1254346,
+ "length": 65458
+ },
+ {
+ "start": 1319808,
+ "length": 65458
+ },
+ {
+ "start": 1385270,
+ "length": 65458
+ },
+ {
+ "start": 1450732,
+ "length": 65458
+ },
+ {
+ "start": 1516194,
+ "length": 65458
+ },
+ {
+ "start": 1581656,
+ "length": 65458
+ },
+ {
+ "start": 1647118,
+ "length": 37405
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'NmSnOIEpWA7upBObXvx80VvNYbUPqJiwhGB3Di2BVaI=",
+ "pad": "b64'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+ "pad2": "b64'"
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.created",
+ "description": "Created by Pixel Camera.",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeCapture"
+ }
+ ]
+ }
+ },
+ "claim.v2": {
+ "instanceID": "a766bf25-d150-664e-85b4-45cae2cb8737",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA SDK for Android",
+ "version": "781252796:793865596"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.assertions/c2pa.hash.multi-asset",
+ "hash": "b64'UMaw/K9C0CkbpdBnDbkS/GkIdIYYKkoP1iTge8Xieuo="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.data",
+ "hash": "b64'kpFvIuZxaDgFDgnaGmwl1sIrgXstpMhfJzimgPPk6ic="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'2dgXjElqRv/Bpvv0Bu54W9pi4Ua+S2zIfVYmEIEtUn0="
+ },
+ {
+ "url": "self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.assertions/c2pa.hash.data.part",
+ "hash": "b64'y0kpFXeDy86udKXF+kj3j8Gc7uRjUfLeOn3T1IjMeik="
+ },
+ {
+ "url": "self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.assertions/c2pa.hash.data.part__1",
+ "hash": "b64'9hj61bEFiY955OMGx0bMnHus9uo+OXPYumvu9SlgPqs="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "b0e50c36040555550a8780d3d6c78494bf8652",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Mobile A 1P ICA G3 L1"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Pixel Camera"
+ },
+ "validity": {
+ "notBefore": "2025-08-14T04:44:09+00:00",
+ "notAfter": "2025-11-11T04:44:08+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2025-08-14T05:51:25+00:00",
+ "certificateInfo": {
+ "serialNumber": "801dd6ca0e3a76f622533f629162f7faa703c9",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Pixel Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google",
+ "CN": "Google Pixel Time Stamping Authority"
+ },
+ "validity": {
+ "notBefore": "2025-05-12T23:43:02+00:00",
+ "notAfter": "2032-11-11T08:43:01+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.signature",
+ "explanation": "timestamp message digest matched: Google Pixel Time Stamping Authority"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.signature",
+ "explanation": "timestamp cert trusted: Google Pixel Time Stamping Authority"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.signature",
+ "explanation": "signing certificate trusted, found in System trust anchors"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.assertions/c2pa.hash.multi-asset",
+ "explanation": "hashed uri matched: self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.assertions/c2pa.hash.multi-asset"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.assertions/c2pa.hash.data",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.hash.data"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.assertions/c2pa.actions.v2",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.assertions/c2pa.hash.data.part",
+ "explanation": "hashed uri matched: self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.assertions/c2pa.hash.data.part"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.assertions/c2pa.hash.data.part__1",
+ "explanation": "hashed uri matched: self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.assertions/c2pa.hash.data.part__1"
+ },
+ {
+ "code": "assertion.dataHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.assertions/c2pa.hash.data",
+ "explanation": "data hash valid"
+ }
+ ],
+ "informational": [
+ {
+ "code": "assertion.dataHash.additionalExclusionsPresent",
+ "url": "self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.assertions/c2pa.hash.data",
+ "explanation": "extra data hash exclusions found"
+ }
+ ],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-03-31T06:07:04.965+00:00"
+ }
+ }
+ ],
+ "jsonGenerator": {
+ "name": "c2pa-rs",
+ "version": "0.78.0"
+ },
+ "usedITL": false,
+ "usedTestCerts": false,
+ "_conformanceToolVersion": {
+ "commit": "d291fb884e02665d169a38928392c2710f6f0953",
+ "shortCommit": "d291fb8",
+ "date": "2026-03-19 20:11:48 -0400",
+ "branch": "HEAD",
+ "generatedAt": "2026-03-23T18:42:00.448Z"
+ }
+}
\ No newline at end of file
diff --git a/src/lib/rubrics/__fixtures__/capture-panorama.signals.json b/src/lib/rubrics/__fixtures__/capture-panorama.signals.json
new file mode 100644
index 0000000..d76d713
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/capture-panorama.signals.json
@@ -0,0 +1,23 @@
+{
+ "rubricName": "C2PA Asset Signals Rubric (Local)",
+ "rubricVersion": "1.0.0",
+ "manifests": [
+ {
+ "assertedBy": {
+ "CN": "Pixel Camera",
+ "O": "Google LLC"
+ },
+ "mimeType": null,
+ "localInceptions": [
+ {
+ "trait": "inception:signal_capturedMediaStitched",
+ "reportText": "Contains Stitched Captured Media",
+ "multiple": false
+ }
+ ],
+ "localTransformations": [],
+ "allActionsIncluded": false,
+ "ingredients": []
+ }
+ ]
+}
diff --git a/src/lib/rubrics/__fixtures__/capture-portrait.conformance.json b/src/lib/rubrics/__fixtures__/capture-portrait.conformance.json
new file mode 100644
index 0000000..c17a9eb
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/capture-portrait.conformance.json
@@ -0,0 +1,71 @@
+{
+ "rubricName": "C2PA Asset Conformance 0.1 Spec 2.2 Rubric",
+ "rubricVersion": "0.1.0",
+ "true": {
+ "validation": [
+ {
+ "trait": "active_manifest_urn",
+ "reportText": "Active manifest URN uses standard 'urn:c2pa:' prefix"
+ },
+ {
+ "trait": "inception_action_position",
+ "reportText": "Inception action is correctly positioned as the first item in the first created actions assertion"
+ },
+ {
+ "trait": "ingredient_relationship_values",
+ "reportText": "All ingredient assertions have valid relationship values"
+ },
+ {
+ "trait": "ingredient_v3_no_active_manifest",
+ "reportText": "Ingredient v3 assertions without activeManifest correctly omit validationResults"
+ },
+ {
+ "trait": "no_deprecated_actions",
+ "reportText": "No deprecated actions used"
+ },
+ {
+ "trait": "no_deprecated_assertions",
+ "reportText": "No deprecated standard assertions found"
+ },
+ {
+ "trait": "no_unsupported_assertions",
+ "reportText": "All standard assertions are supported for Spec 2.2"
+ },
+ {
+ "trait": "review_ratings_datasource",
+ "reportText": "No invalid co-occurrence of reviewRatings and human entry dataSource"
+ },
+ {
+ "trait": "trusted_data_present",
+ "reportText": "Validation results are present for trust analysis"
+ },
+ {
+ "trait": "trusted_success",
+ "reportText": "Asset is trusted"
+ },
+ {
+ "trait": "update_manifest_constraints",
+ "reportText": "Update manifest constraints are satisfied (or manifest is not an update manifest)"
+ },
+ {
+ "trait": "valid_data_present",
+ "reportText": "Validation results are present for integrity analysis"
+ },
+ {
+ "trait": "valid_success",
+ "reportText": "No validation mismatches found"
+ },
+ {
+ "trait": "well_formed_data_present",
+ "reportText": "Validation results are present for structural analysis"
+ },
+ {
+ "trait": "well_formed_success",
+ "reportText": "No structural failures found"
+ }
+ ]
+ },
+ "false": {
+ "validation": []
+ }
+}
diff --git a/src/lib/rubrics/__fixtures__/capture-portrait.json b/src/lib/rubrics/__fixtures__/capture-portrait.json
new file mode 100644
index 0000000..92b317d
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/capture-portrait.json
@@ -0,0 +1,429 @@
+{
+ "@context": {
+ "@vocab": "https://contentcredentials.org/crjson",
+ "extras": "https://contentcredentials.org/crjson/extras"
+ },
+ "manifests": [
+ {
+ "label": "urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743",
+ "assertions": {
+ "c2pa.hash.multi-asset": {
+ "parts": [
+ {
+ "location": {
+ "byteOffset": 0,
+ "length": 1840566
+ },
+ "hashAssertion": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.assertions/c2pa.hash.data.part",
+ "alg": "",
+ "hash": "b64'sq/yH7T7YPpF4Tw3iUO5EvcxzWxYsGQ+QArzmHRwqms=",
+ "pad": "b64'"
+ },
+ "optional": false
+ },
+ {
+ "location": {
+ "byteOffset": 1840566,
+ "length": 25229
+ },
+ "hashAssertion": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.assertions/c2pa.hash.data.part__1",
+ "alg": "",
+ "hash": "b64'FLtYI/jGjTx57ABp59b81x9EJgubOciL5B07/aw2Yrg=",
+ "pad": "b64'"
+ },
+ "optional": true
+ },
+ {
+ "location": {
+ "byteOffset": 1865795,
+ "length": 535559
+ },
+ "hashAssertion": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.assertions/c2pa.hash.data.part__2",
+ "alg": "",
+ "hash": "b64'8nbeFXMcUYIIncKIk9kP7dUsXUXEVRPbCQBccVAQVSc=",
+ "pad": "b64'"
+ },
+ "optional": true
+ },
+ {
+ "location": {
+ "byteOffset": 2401354,
+ "length": 27743
+ },
+ "hashAssertion": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.assertions/c2pa.hash.data.part__3",
+ "alg": "",
+ "hash": "b64'uGTMEEyNwJ8JKN2DAM1KwJNUgHWIS16m0p56PB9XzWo=",
+ "pad": "b64'"
+ },
+ "optional": true
+ },
+ {
+ "location": {
+ "byteOffset": 2429097,
+ "length": 152973
+ },
+ "hashAssertion": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.assertions/c2pa.hash.data.part__4",
+ "alg": "",
+ "hash": "b64'eX+8XKi22onQZz15PteglWztnvETwupWEcz47RfOMSw=",
+ "pad": "b64'"
+ },
+ "optional": true
+ },
+ {
+ "location": {
+ "byteOffset": 2582070,
+ "length": 38657
+ },
+ "hashAssertion": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.assertions/c2pa.hash.data.part__5",
+ "alg": "",
+ "hash": "b64'sL4Ohow86M6Zhcm9wT/KdVPM7uRhdv4M7+meZGiIdXI=",
+ "pad": "b64'"
+ },
+ "optional": true
+ }
+ ],
+ "pad": "b64'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
+ "pad2": "b64'"
+ },
+ "c2pa.hash.data.part__5": {
+ "alg": "sha256",
+ "hash": "b64'CU4Y+1VLcDeuPxwQsmTKWmh7xIwnbO6OcPCprI+zyro=",
+ "pad": "b64'"
+ },
+ "c2pa.hash.data.part__4": {
+ "alg": "sha256",
+ "hash": "b64'E3nHldMoJl5kf2N7zF5DRWTWub0HEQeOEpFMdvukn6o=",
+ "pad": "b64'"
+ },
+ "c2pa.hash.data.part__3": {
+ "alg": "sha256",
+ "hash": "b64'nWm46e83OSz+n80HX/6R9rEFJRXvd21c/6OdR3SOp9Y=",
+ "pad": "b64'"
+ },
+ "c2pa.hash.data.part__2": {
+ "alg": "sha256",
+ "hash": "b64'RvFJTlrDVgN5LAaXDb7FQJUFnifPU5ss0RehVy9cAOU=",
+ "pad": "b64'"
+ },
+ "c2pa.hash.data.part__1": {
+ "alg": "sha256",
+ "hash": "b64'uRKQAiX2b+J3Hph78B2kJh5xX1RDUg0O51XMo0Y2EAc=",
+ "pad": "b64'"
+ },
+ "c2pa.hash.data.part": {
+ "exclusions": [
+ {
+ "start": 6,
+ "length": 29198
+ },
+ {
+ "start": 29226,
+ "length": 1967
+ },
+ {
+ "start": 31197,
+ "length": 65458
+ },
+ {
+ "start": 96659,
+ "length": 65458
+ },
+ {
+ "start": 162121,
+ "length": 65458
+ },
+ {
+ "start": 227583,
+ "length": 65458
+ },
+ {
+ "start": 293045,
+ "length": 65458
+ },
+ {
+ "start": 358507,
+ "length": 65458
+ },
+ {
+ "start": 423969,
+ "length": 65458
+ },
+ {
+ "start": 489431,
+ "length": 6414
+ },
+ {
+ "start": 495845,
+ "length": 8758
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'xqZQt/h/XzUbTQfQqI/SrBya4sMZflHLvpqjC70YGF4=",
+ "pad": "b64'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
+ "pad2": "b64'"
+ },
+ "c2pa.hash.data": {
+ "exclusions": [
+ {
+ "start": 6,
+ "length": 29198
+ },
+ {
+ "start": 29226,
+ "length": 1967
+ },
+ {
+ "start": 31197,
+ "length": 65458
+ },
+ {
+ "start": 96659,
+ "length": 65458
+ },
+ {
+ "start": 162121,
+ "length": 65458
+ },
+ {
+ "start": 227583,
+ "length": 65458
+ },
+ {
+ "start": 293045,
+ "length": 65458
+ },
+ {
+ "start": 358507,
+ "length": 65458
+ },
+ {
+ "start": 423969,
+ "length": 65458
+ },
+ {
+ "start": 489431,
+ "length": 6414
+ },
+ {
+ "start": 495845,
+ "length": 8758
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'Uuh/zYg3hMg6DVYtPnnZIv5T+FFmWpOPEvTRkXTHSSo=",
+ "pad": "b64'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
+ "pad2": "b64'"
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.created",
+ "description": "Created by Pixel Camera.",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+ },
+ {
+ "action": "c2pa.enhanced",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/algorithmicallyEnhanced"
+ }
+ ]
+ }
+ },
+ "claim.v2": {
+ "instanceID": "94e39f14-9398-a760-12ba-4786acc996d5",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA SDK for Android",
+ "version": "781252796:793865596"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.assertions/c2pa.hash.data.part",
+ "hash": "b64'sq/yH7T7YPpF4Tw3iUO5EvcxzWxYsGQ+QArzmHRwqms="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'9VmeETMgnqWVgJ07KE5xb3Kf803CiF61cm8Zhg0arfE="
+ },
+ {
+ "url": "self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.assertions/c2pa.hash.data.part__3",
+ "hash": "b64'uGTMEEyNwJ8JKN2DAM1KwJNUgHWIS16m0p56PB9XzWo="
+ },
+ {
+ "url": "self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.assertions/c2pa.hash.data.part__1",
+ "hash": "b64'FLtYI/jGjTx57ABp59b81x9EJgubOciL5B07/aw2Yrg="
+ },
+ {
+ "url": "self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.assertions/c2pa.hash.multi-asset",
+ "hash": "b64'bUC4sr0ChDJqXp7xWCJWeLud8KNjA1lDgDu+iICcHoM="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.data",
+ "hash": "b64'/JEsLSGOTGuYwWpYsw1HK60R7/a1BmbzABXsGa/T70Y="
+ },
+ {
+ "url": "self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.assertions/c2pa.hash.data.part__2",
+ "hash": "b64'8nbeFXMcUYIIncKIk9kP7dUsXUXEVRPbCQBccVAQVSc="
+ },
+ {
+ "url": "self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.assertions/c2pa.hash.data.part__4",
+ "hash": "b64'eX+8XKi22onQZz15PteglWztnvETwupWEcz47RfOMSw="
+ },
+ {
+ "url": "self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.assertions/c2pa.hash.data.part__5",
+ "hash": "b64'sL4Ohow86M6Zhcm9wT/KdVPM7uRhdv4M7+meZGiIdXI="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "7273227f8b2476fd6d0dd50359a52dd3798a91",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Mobile A 1P ICA G3 L1"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Pixel Camera"
+ },
+ "validity": {
+ "notBefore": "2025-08-14T04:44:05+00:00",
+ "notAfter": "2025-11-11T04:44:04+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2025-08-14T05:49:17+00:00",
+ "certificateInfo": {
+ "serialNumber": "801dd6ca0e3a76f622533f629162f7faa703c9",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Pixel Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google",
+ "CN": "Google Pixel Time Stamping Authority"
+ },
+ "validity": {
+ "notBefore": "2025-05-12T23:43:02+00:00",
+ "notAfter": "2032-11-11T08:43:01+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.signature",
+ "explanation": "timestamp message digest matched: Google Pixel Time Stamping Authority"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.signature",
+ "explanation": "timestamp cert trusted: Google Pixel Time Stamping Authority"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.signature",
+ "explanation": "signing certificate trusted, found in System trust anchors"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.assertions/c2pa.hash.data.part",
+ "explanation": "hashed uri matched: self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.assertions/c2pa.hash.data.part"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.assertions/c2pa.actions.v2",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.assertions/c2pa.hash.data.part__3",
+ "explanation": "hashed uri matched: self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.assertions/c2pa.hash.data.part__3"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.assertions/c2pa.hash.data.part__1",
+ "explanation": "hashed uri matched: self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.assertions/c2pa.hash.data.part__1"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.assertions/c2pa.hash.multi-asset",
+ "explanation": "hashed uri matched: self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.assertions/c2pa.hash.multi-asset"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.assertions/c2pa.hash.data",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.hash.data"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.assertions/c2pa.hash.data.part__2",
+ "explanation": "hashed uri matched: self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.assertions/c2pa.hash.data.part__2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.assertions/c2pa.hash.data.part__4",
+ "explanation": "hashed uri matched: self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.assertions/c2pa.hash.data.part__4"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.assertions/c2pa.hash.data.part__5",
+ "explanation": "hashed uri matched: self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.assertions/c2pa.hash.data.part__5"
+ },
+ {
+ "code": "assertion.dataHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.assertions/c2pa.hash.data",
+ "explanation": "data hash valid"
+ }
+ ],
+ "informational": [
+ {
+ "code": "assertion.dataHash.additionalExclusionsPresent",
+ "url": "self#jumbf=/c2pa/urn:c2pa:6c1893e6-9432-6e9a-21c2-45c1ec9a3743/c2pa.assertions/c2pa.hash.data",
+ "explanation": "extra data hash exclusions found"
+ }
+ ],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-03-31T04:19:43.487+00:00"
+ }
+ }
+ ],
+ "jsonGenerator": {
+ "name": "c2pa-rs",
+ "version": "0.78.0"
+ },
+ "usedITL": false,
+ "usedTestCerts": false,
+ "_conformanceToolVersion": {
+ "commit": "d291fb884e02665d169a38928392c2710f6f0953",
+ "shortCommit": "d291fb8",
+ "date": "2026-03-19 20:11:48 -0400",
+ "branch": "HEAD",
+ "generatedAt": "2026-03-23T18:42:00.448Z"
+ }
+}
\ No newline at end of file
diff --git a/src/lib/rubrics/__fixtures__/capture-portrait.signals.json b/src/lib/rubrics/__fixtures__/capture-portrait.signals.json
new file mode 100644
index 0000000..8290ea4
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/capture-portrait.signals.json
@@ -0,0 +1,29 @@
+{
+ "rubricName": "C2PA Asset Signals Rubric (Local)",
+ "rubricVersion": "1.0.0",
+ "manifests": [
+ {
+ "assertedBy": {
+ "CN": "Pixel Camera",
+ "O": "Google LLC"
+ },
+ "mimeType": null,
+ "localInceptions": [
+ {
+ "trait": "inception:signal_capturedMedia",
+ "reportText": "Contains Captured Media",
+ "multiple": false
+ }
+ ],
+ "localTransformations": [
+ {
+ "trait": "transformation:signal_nonEditorial",
+ "reportText": "Contains Non-editorial Transformations",
+ "multiple": false
+ }
+ ],
+ "allActionsIncluded": false,
+ "ingredients": []
+ }
+ ]
+}
diff --git a/src/lib/rubrics/__fixtures__/capture-proResZoom.conformance.json b/src/lib/rubrics/__fixtures__/capture-proResZoom.conformance.json
new file mode 100644
index 0000000..c17a9eb
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/capture-proResZoom.conformance.json
@@ -0,0 +1,71 @@
+{
+ "rubricName": "C2PA Asset Conformance 0.1 Spec 2.2 Rubric",
+ "rubricVersion": "0.1.0",
+ "true": {
+ "validation": [
+ {
+ "trait": "active_manifest_urn",
+ "reportText": "Active manifest URN uses standard 'urn:c2pa:' prefix"
+ },
+ {
+ "trait": "inception_action_position",
+ "reportText": "Inception action is correctly positioned as the first item in the first created actions assertion"
+ },
+ {
+ "trait": "ingredient_relationship_values",
+ "reportText": "All ingredient assertions have valid relationship values"
+ },
+ {
+ "trait": "ingredient_v3_no_active_manifest",
+ "reportText": "Ingredient v3 assertions without activeManifest correctly omit validationResults"
+ },
+ {
+ "trait": "no_deprecated_actions",
+ "reportText": "No deprecated actions used"
+ },
+ {
+ "trait": "no_deprecated_assertions",
+ "reportText": "No deprecated standard assertions found"
+ },
+ {
+ "trait": "no_unsupported_assertions",
+ "reportText": "All standard assertions are supported for Spec 2.2"
+ },
+ {
+ "trait": "review_ratings_datasource",
+ "reportText": "No invalid co-occurrence of reviewRatings and human entry dataSource"
+ },
+ {
+ "trait": "trusted_data_present",
+ "reportText": "Validation results are present for trust analysis"
+ },
+ {
+ "trait": "trusted_success",
+ "reportText": "Asset is trusted"
+ },
+ {
+ "trait": "update_manifest_constraints",
+ "reportText": "Update manifest constraints are satisfied (or manifest is not an update manifest)"
+ },
+ {
+ "trait": "valid_data_present",
+ "reportText": "Validation results are present for integrity analysis"
+ },
+ {
+ "trait": "valid_success",
+ "reportText": "No validation mismatches found"
+ },
+ {
+ "trait": "well_formed_data_present",
+ "reportText": "Validation results are present for structural analysis"
+ },
+ {
+ "trait": "well_formed_success",
+ "reportText": "No structural failures found"
+ }
+ ]
+ },
+ "false": {
+ "validation": []
+ }
+}
diff --git a/src/lib/rubrics/__fixtures__/capture-proResZoom.json b/src/lib/rubrics/__fixtures__/capture-proResZoom.json
new file mode 100644
index 0000000..0d4d5d9
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/capture-proResZoom.json
@@ -0,0 +1,297 @@
+{
+ "@context": {
+ "@vocab": "https://contentcredentials.org/crjson",
+ "extras": "https://contentcredentials.org/crjson/extras"
+ },
+ "manifests": [
+ {
+ "label": "urn:c2pa:b354b55a-3c74-6f40-50c5-4d1ce32f2693",
+ "assertions": {
+ "c2pa.hash.multi-asset": {
+ "parts": [
+ {
+ "location": {
+ "byteOffset": 0,
+ "length": 1704831
+ },
+ "hashAssertion": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:b354b55a-3c74-6f40-50c5-4d1ce32f2693/c2pa.assertions/c2pa.hash.data.part",
+ "alg": "",
+ "hash": "b64'nw2mOhnKcRi/3aFehvn2XBlntS7sjewyNBDedz19J5o=",
+ "pad": "b64'"
+ },
+ "optional": false
+ },
+ {
+ "location": {
+ "byteOffset": 1704831,
+ "length": 10126
+ },
+ "hashAssertion": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:b354b55a-3c74-6f40-50c5-4d1ce32f2693/c2pa.assertions/c2pa.hash.data.part__1",
+ "alg": "",
+ "hash": "b64'kHH7LJ6rEN+koG5g5StX0DMpQhf070e24oNWBSznCE0=",
+ "pad": "b64'"
+ },
+ "optional": true
+ }
+ ],
+ "pad": "b64'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
+ "pad2": "b64'AA=="
+ },
+ "c2pa.hash.data.part__1": {
+ "alg": "sha256",
+ "hash": "b64'SsvkW/vqnouXM5fOxf+u67snRcsSjFjoruRY4vSHrEQ=",
+ "pad": "b64'"
+ },
+ "c2pa.hash.data.part": {
+ "exclusions": [
+ {
+ "start": 6,
+ "length": 26445
+ },
+ {
+ "start": 26469,
+ "length": 6617
+ },
+ {
+ "start": 33704,
+ "length": 1158
+ },
+ {
+ "start": 34866,
+ "length": 65458
+ },
+ {
+ "start": 100328,
+ "length": 65458
+ },
+ {
+ "start": 165790,
+ "length": 65458
+ },
+ {
+ "start": 231252,
+ "length": 65458
+ },
+ {
+ "start": 296714,
+ "length": 43457
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'+hrhGiD8mm7XKd+mrEJSXL+xRyFUHLMIR1IeaZwISQI=",
+ "pad": "b64'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+ "pad2": "b64'"
+ },
+ "c2pa.hash.data": {
+ "exclusions": [
+ {
+ "start": 6,
+ "length": 26445
+ },
+ {
+ "start": 26469,
+ "length": 6617
+ },
+ {
+ "start": 33704,
+ "length": 1158
+ },
+ {
+ "start": 34866,
+ "length": 65458
+ },
+ {
+ "start": 100328,
+ "length": 65458
+ },
+ {
+ "start": 165790,
+ "length": 65458
+ },
+ {
+ "start": 231252,
+ "length": 65458
+ },
+ {
+ "start": 296714,
+ "length": 43457
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'oce/fALFpj8y5udJryt4aUcZk7Gt5z4VgtBhCgX2KYA=",
+ "pad": "b64'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+ "pad2": "b64'"
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.created",
+ "description": "Created by Pixel Camera.",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+ },
+ {
+ "action": "c2pa.edited",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeWithTrainedAlgorithmicMedia"
+ }
+ ]
+ }
+ },
+ "claim.v2": {
+ "instanceID": "c366be8f-ca1e-bfc3-78b1-4437fd277124",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:b354b55a-3c74-6f40-50c5-4d1ce32f2693/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA SDK for Android",
+ "version": "781252796:793865596"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=/c2pa/urn:c2pa:b354b55a-3c74-6f40-50c5-4d1ce32f2693/c2pa.assertions/c2pa.hash.data.part__1",
+ "hash": "b64'kHH7LJ6rEN+koG5g5StX0DMpQhf070e24oNWBSznCE0="
+ },
+ {
+ "url": "self#jumbf=/c2pa/urn:c2pa:b354b55a-3c74-6f40-50c5-4d1ce32f2693/c2pa.assertions/c2pa.hash.data.part",
+ "hash": "b64'nw2mOhnKcRi/3aFehvn2XBlntS7sjewyNBDedz19J5o="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'31P57YO8Fglg7y5JGMOz6KGxUB4cZQ+izmatHpKW/5Y="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.data",
+ "hash": "b64'mEFJCECrQOUGTQtnIXFBr5bCdvh+Cltuc5f4fCoreGE="
+ },
+ {
+ "url": "self#jumbf=/c2pa/urn:c2pa:b354b55a-3c74-6f40-50c5-4d1ce32f2693/c2pa.assertions/c2pa.hash.multi-asset",
+ "hash": "b64'ulm9vPlZZ0rsQfz3WhB6KpmOtQAvAQ2iACfQlvbInuI="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "4f877db462f314cb377326d02e5cfb1039be47",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Mobile A 1P ICA G3 L1"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Pixel Camera"
+ },
+ "validity": {
+ "notBefore": "2025-08-14T04:44:05+00:00",
+ "notAfter": "2025-11-11T04:44:04+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2025-08-14T05:47:00+00:00",
+ "certificateInfo": {
+ "serialNumber": "801dd6ca0e3a76f622533f629162f7faa703c9",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Pixel Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google",
+ "CN": "Google Pixel Time Stamping Authority"
+ },
+ "validity": {
+ "notBefore": "2025-05-12T23:43:02+00:00",
+ "notAfter": "2032-11-11T08:43:01+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b354b55a-3c74-6f40-50c5-4d1ce32f2693/c2pa.signature",
+ "explanation": "timestamp message digest matched: Google Pixel Time Stamping Authority"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b354b55a-3c74-6f40-50c5-4d1ce32f2693/c2pa.signature",
+ "explanation": "timestamp cert trusted: Google Pixel Time Stamping Authority"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b354b55a-3c74-6f40-50c5-4d1ce32f2693/c2pa.signature",
+ "explanation": "signing certificate trusted, found in System trust anchors"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b354b55a-3c74-6f40-50c5-4d1ce32f2693/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b354b55a-3c74-6f40-50c5-4d1ce32f2693/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b354b55a-3c74-6f40-50c5-4d1ce32f2693/c2pa.assertions/c2pa.hash.data.part__1",
+ "explanation": "hashed uri matched: self#jumbf=/c2pa/urn:c2pa:b354b55a-3c74-6f40-50c5-4d1ce32f2693/c2pa.assertions/c2pa.hash.data.part__1"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b354b55a-3c74-6f40-50c5-4d1ce32f2693/c2pa.assertions/c2pa.hash.data.part",
+ "explanation": "hashed uri matched: self#jumbf=/c2pa/urn:c2pa:b354b55a-3c74-6f40-50c5-4d1ce32f2693/c2pa.assertions/c2pa.hash.data.part"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b354b55a-3c74-6f40-50c5-4d1ce32f2693/c2pa.assertions/c2pa.actions.v2",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b354b55a-3c74-6f40-50c5-4d1ce32f2693/c2pa.assertions/c2pa.hash.data",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.hash.data"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b354b55a-3c74-6f40-50c5-4d1ce32f2693/c2pa.assertions/c2pa.hash.multi-asset",
+ "explanation": "hashed uri matched: self#jumbf=/c2pa/urn:c2pa:b354b55a-3c74-6f40-50c5-4d1ce32f2693/c2pa.assertions/c2pa.hash.multi-asset"
+ },
+ {
+ "code": "assertion.dataHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b354b55a-3c74-6f40-50c5-4d1ce32f2693/c2pa.assertions/c2pa.hash.data",
+ "explanation": "data hash valid"
+ }
+ ],
+ "informational": [
+ {
+ "code": "assertion.dataHash.additionalExclusionsPresent",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b354b55a-3c74-6f40-50c5-4d1ce32f2693/c2pa.assertions/c2pa.hash.data",
+ "explanation": "extra data hash exclusions found"
+ }
+ ],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-03-31T05:36:44.324+00:00"
+ }
+ }
+ ],
+ "jsonGenerator": {
+ "name": "c2pa-rs",
+ "version": "0.78.0"
+ },
+ "usedITL": false,
+ "usedTestCerts": false,
+ "_conformanceToolVersion": {
+ "commit": "d291fb884e02665d169a38928392c2710f6f0953",
+ "shortCommit": "d291fb8",
+ "date": "2026-03-19 20:11:48 -0400",
+ "branch": "HEAD",
+ "generatedAt": "2026-03-23T18:42:00.448Z"
+ }
+}
\ No newline at end of file
diff --git a/src/lib/rubrics/__fixtures__/capture-proResZoom.signals.json b/src/lib/rubrics/__fixtures__/capture-proResZoom.signals.json
new file mode 100644
index 0000000..51fb140
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/capture-proResZoom.signals.json
@@ -0,0 +1,29 @@
+{
+ "rubricName": "C2PA Asset Signals Rubric (Local)",
+ "rubricVersion": "1.0.0",
+ "manifests": [
+ {
+ "assertedBy": {
+ "CN": "Pixel Camera",
+ "O": "Google LLC"
+ },
+ "mimeType": null,
+ "localInceptions": [
+ {
+ "trait": "inception:signal_capturedMedia",
+ "reportText": "Contains Captured Media",
+ "multiple": false
+ }
+ ],
+ "localTransformations": [
+ {
+ "trait": "transformation:signal_editorialAI",
+ "reportText": "Contains Editorial GenAI Transformations",
+ "multiple": false
+ }
+ ],
+ "allActionsIncluded": false,
+ "ingredients": []
+ }
+ ]
+}
diff --git a/src/lib/rubrics/__fixtures__/capture.conformance.json b/src/lib/rubrics/__fixtures__/capture.conformance.json
new file mode 100644
index 0000000..c17a9eb
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/capture.conformance.json
@@ -0,0 +1,71 @@
+{
+ "rubricName": "C2PA Asset Conformance 0.1 Spec 2.2 Rubric",
+ "rubricVersion": "0.1.0",
+ "true": {
+ "validation": [
+ {
+ "trait": "active_manifest_urn",
+ "reportText": "Active manifest URN uses standard 'urn:c2pa:' prefix"
+ },
+ {
+ "trait": "inception_action_position",
+ "reportText": "Inception action is correctly positioned as the first item in the first created actions assertion"
+ },
+ {
+ "trait": "ingredient_relationship_values",
+ "reportText": "All ingredient assertions have valid relationship values"
+ },
+ {
+ "trait": "ingredient_v3_no_active_manifest",
+ "reportText": "Ingredient v3 assertions without activeManifest correctly omit validationResults"
+ },
+ {
+ "trait": "no_deprecated_actions",
+ "reportText": "No deprecated actions used"
+ },
+ {
+ "trait": "no_deprecated_assertions",
+ "reportText": "No deprecated standard assertions found"
+ },
+ {
+ "trait": "no_unsupported_assertions",
+ "reportText": "All standard assertions are supported for Spec 2.2"
+ },
+ {
+ "trait": "review_ratings_datasource",
+ "reportText": "No invalid co-occurrence of reviewRatings and human entry dataSource"
+ },
+ {
+ "trait": "trusted_data_present",
+ "reportText": "Validation results are present for trust analysis"
+ },
+ {
+ "trait": "trusted_success",
+ "reportText": "Asset is trusted"
+ },
+ {
+ "trait": "update_manifest_constraints",
+ "reportText": "Update manifest constraints are satisfied (or manifest is not an update manifest)"
+ },
+ {
+ "trait": "valid_data_present",
+ "reportText": "Validation results are present for integrity analysis"
+ },
+ {
+ "trait": "valid_success",
+ "reportText": "No validation mismatches found"
+ },
+ {
+ "trait": "well_formed_data_present",
+ "reportText": "Validation results are present for structural analysis"
+ },
+ {
+ "trait": "well_formed_success",
+ "reportText": "No structural failures found"
+ }
+ ]
+ },
+ "false": {
+ "validation": []
+ }
+}
diff --git a/src/lib/rubrics/__fixtures__/capture.json b/src/lib/rubrics/__fixtures__/capture.json
new file mode 100644
index 0000000..a974d1e
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/capture.json
@@ -0,0 +1,317 @@
+{
+ "@context": {
+ "@vocab": "https://contentcredentials.org/crjson",
+ "extras": "https://contentcredentials.org/crjson/extras"
+ },
+ "manifests": [
+ {
+ "label": "urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8",
+ "assertions": {
+ "c2pa.hash.multi-asset": {
+ "parts": [
+ {
+ "location": {
+ "byteOffset": 0,
+ "length": 2201646
+ },
+ "hashAssertion": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.hash.data.part",
+ "alg": "",
+ "hash": "b64'f+fgy1+OerCwgBUaf7B+3/YQZO0Rs3OCyV91hEo3hp0=",
+ "pad": "b64'"
+ },
+ "optional": false
+ },
+ {
+ "location": {
+ "byteOffset": 2201646,
+ "length": 104882
+ },
+ "hashAssertion": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.hash.data.part__1",
+ "alg": "",
+ "hash": "b64'6vBDDLKYgbDT6fD+g9+/myvdIv/2NFVpP6VUpIe99E0=",
+ "pad": "b64'"
+ },
+ "optional": true
+ }
+ ],
+ "pad": "b64'AAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+ "pad2": "b64'AA=="
+ },
+ "c2pa.hash.data.part__1": {
+ "alg": "sha256",
+ "hash": "b64'YCKF9pJdtr0cJFTDlZ+m91kmHEJcP/g+MdiTKaczBck=",
+ "pad": "b64'"
+ },
+ "c2pa.hash.data.part": {
+ "exclusions": [
+ {
+ "start": 6,
+ "length": 29033
+ },
+ {
+ "start": 29057,
+ "length": 6687
+ },
+ {
+ "start": 36362,
+ "length": 1159
+ },
+ {
+ "start": 37525,
+ "length": 65458
+ },
+ {
+ "start": 102987,
+ "length": 65458
+ },
+ {
+ "start": 168449,
+ "length": 65458
+ },
+ {
+ "start": 233911,
+ "length": 65458
+ },
+ {
+ "start": 299373,
+ "length": 65458
+ },
+ {
+ "start": 364835,
+ "length": 65458
+ },
+ {
+ "start": 430297,
+ "length": 65458
+ },
+ {
+ "start": 495759,
+ "length": 29380
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'MC2WtOu48zBrMprYlxnm2hKHj2bKI9h64crE6HGfM8Y=",
+ "pad": "b64'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+ "pad2": "b64'"
+ },
+ "c2pa.hash.data": {
+ "exclusions": [
+ {
+ "start": 6,
+ "length": 29033
+ },
+ {
+ "start": 29057,
+ "length": 6687
+ },
+ {
+ "start": 36362,
+ "length": 1159
+ },
+ {
+ "start": 37525,
+ "length": 65458
+ },
+ {
+ "start": 102987,
+ "length": 65458
+ },
+ {
+ "start": 168449,
+ "length": 65458
+ },
+ {
+ "start": 233911,
+ "length": 65458
+ },
+ {
+ "start": 299373,
+ "length": 65458
+ },
+ {
+ "start": 364835,
+ "length": 65458
+ },
+ {
+ "start": 430297,
+ "length": 65458
+ },
+ {
+ "start": 495759,
+ "length": 29380
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'pHJmHV6VE0ZYSue93tkLM+/tRKhmegKkPjTby7eGNA0=",
+ "pad": "b64'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+ "pad2": "b64'"
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.created",
+ "description": "Created by Pixel Camera.",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+ }
+ ]
+ }
+ },
+ "claim.v2": {
+ "instanceID": "66808d7a-4558-121c-751a-4ec0fcfafdb6",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA SDK for Android",
+ "version": "781252796:793865596"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.hash.data.part__1",
+ "hash": "b64'6vBDDLKYgbDT6fD+g9+/myvdIv/2NFVpP6VUpIe99E0="
+ },
+ {
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.hash.data.part",
+ "hash": "b64'f+fgy1+OerCwgBUaf7B+3/YQZO0Rs3OCyV91hEo3hp0="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'Axs3an5HFczmpjJiejh1Z5lKQi2MVSH8g1NVbjSNOjI="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.data",
+ "hash": "b64'xOsMWdfp1/ZlFPCZgj8IFJ93ix2XCG8VutFmkihEjCw="
+ },
+ {
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.hash.multi-asset",
+ "hash": "b64'8osZQ4n42hdQlS/GPQzfZcNgtWus+PhQ8ImQChIP3qk="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "81ce674ee783b652548ba056ef34993be9adee",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Mobile A 1P ICA G3 L1"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Pixel Camera"
+ },
+ "validity": {
+ "notBefore": "2025-08-14T04:44:05+00:00",
+ "notAfter": "2025-11-11T04:44:04+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2025-08-14T05:48:57+00:00",
+ "certificateInfo": {
+ "serialNumber": "801dd6ca0e3a76f622533f629162f7faa703c9",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Pixel Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google",
+ "CN": "Google Pixel Time Stamping Authority"
+ },
+ "validity": {
+ "notBefore": "2025-05-12T23:43:02+00:00",
+ "notAfter": "2032-11-11T08:43:01+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.signature",
+ "explanation": "timestamp message digest matched: Google Pixel Time Stamping Authority"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.signature",
+ "explanation": "timestamp cert trusted: Google Pixel Time Stamping Authority"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.signature",
+ "explanation": "signing certificate trusted, found in System trust anchors"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.hash.data.part__1",
+ "explanation": "hashed uri matched: self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.hash.data.part__1"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.hash.data.part",
+ "explanation": "hashed uri matched: self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.hash.data.part"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.actions.v2",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.hash.data",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.hash.data"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.hash.multi-asset",
+ "explanation": "hashed uri matched: self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.hash.multi-asset"
+ },
+ {
+ "code": "assertion.dataHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.hash.data",
+ "explanation": "data hash valid"
+ }
+ ],
+ "informational": [
+ {
+ "code": "assertion.dataHash.additionalExclusionsPresent",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.hash.data",
+ "explanation": "extra data hash exclusions found"
+ }
+ ],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-04-01T15:54:04.585+00:00"
+ }
+ }
+ ],
+ "jsonGenerator": {
+ "name": "c2pa-rs",
+ "version": "0.78.0"
+ },
+ "usedITL": false,
+ "usedTestCerts": false,
+ "_conformanceToolVersion": {
+ "commit": "d209e393a529f8f54e06842aea3216a558b1caa3",
+ "shortCommit": "d209e39",
+ "date": "2026-03-31 12:02:12 -0400",
+ "branch": "HEAD",
+ "generatedAt": "2026-03-31T16:07:04.350Z"
+ }
+}
\ No newline at end of file
diff --git a/src/lib/rubrics/__fixtures__/capture.signals.json b/src/lib/rubrics/__fixtures__/capture.signals.json
new file mode 100644
index 0000000..d51fed1
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/capture.signals.json
@@ -0,0 +1,23 @@
+{
+ "rubricName": "C2PA Asset Signals Rubric (Local)",
+ "rubricVersion": "1.0.0",
+ "manifests": [
+ {
+ "assertedBy": {
+ "CN": "Pixel Camera",
+ "O": "Google LLC"
+ },
+ "mimeType": null,
+ "localInceptions": [
+ {
+ "trait": "inception:signal_capturedMedia",
+ "reportText": "Contains Captured Media",
+ "multiple": false
+ }
+ ],
+ "localTransformations": [],
+ "allActionsIncluded": false,
+ "ingredients": []
+ }
+ ]
+}
diff --git a/src/lib/rubrics/__fixtures__/capture2genai.conformance.json b/src/lib/rubrics/__fixtures__/capture2genai.conformance.json
new file mode 100644
index 0000000..c17a9eb
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/capture2genai.conformance.json
@@ -0,0 +1,71 @@
+{
+ "rubricName": "C2PA Asset Conformance 0.1 Spec 2.2 Rubric",
+ "rubricVersion": "0.1.0",
+ "true": {
+ "validation": [
+ {
+ "trait": "active_manifest_urn",
+ "reportText": "Active manifest URN uses standard 'urn:c2pa:' prefix"
+ },
+ {
+ "trait": "inception_action_position",
+ "reportText": "Inception action is correctly positioned as the first item in the first created actions assertion"
+ },
+ {
+ "trait": "ingredient_relationship_values",
+ "reportText": "All ingredient assertions have valid relationship values"
+ },
+ {
+ "trait": "ingredient_v3_no_active_manifest",
+ "reportText": "Ingredient v3 assertions without activeManifest correctly omit validationResults"
+ },
+ {
+ "trait": "no_deprecated_actions",
+ "reportText": "No deprecated actions used"
+ },
+ {
+ "trait": "no_deprecated_assertions",
+ "reportText": "No deprecated standard assertions found"
+ },
+ {
+ "trait": "no_unsupported_assertions",
+ "reportText": "All standard assertions are supported for Spec 2.2"
+ },
+ {
+ "trait": "review_ratings_datasource",
+ "reportText": "No invalid co-occurrence of reviewRatings and human entry dataSource"
+ },
+ {
+ "trait": "trusted_data_present",
+ "reportText": "Validation results are present for trust analysis"
+ },
+ {
+ "trait": "trusted_success",
+ "reportText": "Asset is trusted"
+ },
+ {
+ "trait": "update_manifest_constraints",
+ "reportText": "Update manifest constraints are satisfied (or manifest is not an update manifest)"
+ },
+ {
+ "trait": "valid_data_present",
+ "reportText": "Validation results are present for integrity analysis"
+ },
+ {
+ "trait": "valid_success",
+ "reportText": "No validation mismatches found"
+ },
+ {
+ "trait": "well_formed_data_present",
+ "reportText": "Validation results are present for structural analysis"
+ },
+ {
+ "trait": "well_formed_success",
+ "reportText": "No structural failures found"
+ }
+ ]
+ },
+ "false": {
+ "validation": []
+ }
+}
diff --git a/src/lib/rubrics/__fixtures__/capture2genai.json b/src/lib/rubrics/__fixtures__/capture2genai.json
new file mode 100644
index 0000000..c310280
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/capture2genai.json
@@ -0,0 +1,688 @@
+{
+ "@context": {
+ "@vocab": "https://contentcredentials.org/crjson",
+ "extras": "https://contentcredentials.org/crjson/extras"
+ },
+ "manifests": [
+ {
+ "label": "urn:c2pa:763fbc99-427d-a47d-35dc-4fbdeff1f4ef",
+ "assertions": {
+ "c2pa.hash.data": {
+ "exclusions": [
+ {
+ "start": 33,
+ "length": 23121
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'R9PP+6cdxwGtKUTmslZ/vx6ILS8x4VdJsATtH5odtSo=",
+ "pad": "b64'AAAAAAAAAAAAAAAAAA=="
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.created",
+ "description": "Created by Google Generative AI.",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia",
+ "parameters": {
+ "ingredients": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'/PqDQECJt0CU3rLGrvT9jSkaFqDuctFYfgoejBZTeZw=",
+ "pad": "b64'"
+ }
+ ]
+ }
+ },
+ {
+ "action": "c2pa.edited",
+ "description": "Applied imperceptible SynthID watermark.",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"
+ }
+ ]
+ },
+ "c2pa.ingredient.v3": {
+ "relationship": "inputTo",
+ "dc:format": "image/jpeg",
+ "validationResults": {
+ "activeManifest": {
+ "success": [
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:1096b09b-3554-06fe-7328-478ac267f051/c2pa.signature"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:1096b09b-3554-06fe-7328-478ac267f051/c2pa.signature"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:1096b09b-3554-06fe-7328-478ac267f051/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:1096b09b-3554-06fe-7328-478ac267f051/c2pa.signature"
+ },
+ {
+ "code": "signingCredential.ocsp.notRevoked",
+ "url": "self#jumbf=/c2pa/urn:c2pa:1096b09b-3554-06fe-7328-478ac267f051/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:1096b09b-3554-06fe-7328-478ac267f051/c2pa.signature"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:1096b09b-3554-06fe-7328-478ac267f051/c2pa.assertions/c2pa.ingredient.v3"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:1096b09b-3554-06fe-7328-478ac267f051/c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:1096b09b-3554-06fe-7328-478ac267f051/c2pa.assertions/c2pa.hash.data"
+ },
+ {
+ "code": "ingredient.claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:1096b09b-3554-06fe-7328-478ac267f051/c2pa.assertions/c2pa.ingredient.v3"
+ },
+ {
+ "code": "assertion.dataHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:1096b09b-3554-06fe-7328-478ac267f051/c2pa.assertions/c2pa.hash.data"
+ }
+ ],
+ "informational": [],
+ "failure": []
+ },
+ "ingredientDeltas": [
+ {
+ "ingredientAssertionURI": "self#jumbf=/c2pa/urn:c2pa:1096b09b-3554-06fe-7328-478ac267f051/c2pa.assertions/c2pa.ingredient.v3",
+ "validationDeltas": {
+ "success": [],
+ "informational": [],
+ "failure": []
+ }
+ }
+ ]
+ },
+ "activeManifest": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:1096b09b-3554-06fe-7328-478ac267f051",
+ "hash": "b64'wE9Tl0MD4vycmSEplOdrfn/BX74Odzj9inxUmeVXlYU=",
+ "pad": "b64'"
+ },
+ "claimSignature": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:1096b09b-3554-06fe-7328-478ac267f051/c2pa.signature",
+ "hash": "b64'xGf+jYOuDbGKLO2RS1ir4BBIpRCQ9mDO8T5D+qh3vNY=",
+ "pad": "b64'"
+ },
+ "description": "Input ingredient 0"
+ }
+ },
+ "claim.v2": {
+ "instanceID": "e1598e1b-3150-a3ee-bf46-4fc480f892a3",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:763fbc99-427d-a47d-35dc-4fbdeff1f4ef/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA Core Generator Library",
+ "version": "894052442:894052442"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'/PqDQECJt0CU3rLGrvT9jSkaFqDuctFYfgoejBZTeZw="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'rVSfAMu2v85r/Y24M5UdBCv+RY01vXoLj14SIyAH3UY="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.data",
+ "hash": "b64'xf6ozMV+ZsiCZo/jrr9Zch1rJsxHjtEYF0HTftOWo+A="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "9eaf156281a94902165b48ff58a96d24981ece",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Media Services 1P ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "OU": "Google System 60032",
+ "CN": "Google Media Processing Services"
+ },
+ "validity": {
+ "notBefore": "2026-02-17T15:17:12+00:00",
+ "notAfter": "2027-02-12T15:17:11+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2026-04-06T19:11:54+00:00",
+ "certificateInfo": {
+ "serialNumber": "a3e6ce9b0e2d6c0443cb71902c6d8f891dd17c",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Core Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google Core Time Stamping Authority T8"
+ },
+ "validity": {
+ "notBefore": "2025-09-08T13:48:53+00:00",
+ "notAfter": "2031-09-09T01:48:52+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:763fbc99-427d-a47d-35dc-4fbdeff1f4ef/c2pa.signature",
+ "explanation": "timestamp message digest matched: Google Core Time Stamping Authority T8"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:763fbc99-427d-a47d-35dc-4fbdeff1f4ef/c2pa.signature",
+ "explanation": "timestamp cert trusted: Google Core Time Stamping Authority T8"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:763fbc99-427d-a47d-35dc-4fbdeff1f4ef/c2pa.signature",
+ "explanation": "signing certificate trusted, found in System trust anchors"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:763fbc99-427d-a47d-35dc-4fbdeff1f4ef/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:763fbc99-427d-a47d-35dc-4fbdeff1f4ef/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:763fbc99-427d-a47d-35dc-4fbdeff1f4ef/c2pa.assertions/c2pa.ingredient.v3",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:763fbc99-427d-a47d-35dc-4fbdeff1f4ef/c2pa.assertions/c2pa.actions.v2",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:763fbc99-427d-a47d-35dc-4fbdeff1f4ef/c2pa.assertions/c2pa.hash.data",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.hash.data"
+ },
+ {
+ "code": "assertion.dataHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:763fbc99-427d-a47d-35dc-4fbdeff1f4ef/c2pa.assertions/c2pa.hash.data",
+ "explanation": "data hash valid"
+ }
+ ],
+ "informational": [
+ {
+ "code": "signingCredential.ocsp.notRevoked",
+ "url": "self#jumbf=/c2pa/urn:c2pa:763fbc99-427d-a47d-35dc-4fbdeff1f4ef/c2pa.signature",
+ "explanation": "signing cert not revoked: 3538769668273185232431161852199326737017347790"
+ }
+ ],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-04-06T19:37:47.622+00:00"
+ }
+ },
+ {
+ "label": "urn:c2pa:1096b09b-3554-06fe-7328-478ac267f051",
+ "assertions": {
+ "c2pa.hash.data": {
+ "exclusions": [
+ {
+ "start": 937,
+ "length": 14913
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'peZi6rphpMN/NMY2e+arqniEe07mEfzeAiG5IHhESOo=",
+ "pad": "b64'AAAAAAAAAAAAAAAA"
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.opened",
+ "description": "Opened by Google",
+ "parameters": {
+ "ingredients": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'reXvV5FU5mS4CI22lO/WmcO4rWqoOXuuplk7+Vsm5q4=",
+ "pad": "b64'"
+ }
+ ]
+ }
+ },
+ {
+ "action": "c2pa.transcoded",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/algorithmicallyEnhanced",
+ "parameters": {
+ "ingredients": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'reXvV5FU5mS4CI22lO/WmcO4rWqoOXuuplk7+Vsm5q4=",
+ "pad": "b64'"
+ }
+ ]
+ }
+ }
+ ]
+ },
+ "c2pa.ingredient.v3": {
+ "relationship": "parentOf",
+ "dc:format": "image/jpeg",
+ "validationResults": {
+ "activeManifest": {
+ "success": [
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.signature"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.signature"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.signature"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.hash.data.part__1"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.hash.data.part"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.hash.data"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.hash.multi-asset"
+ },
+ {
+ "code": "assertion.dataHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.hash.data"
+ }
+ ],
+ "informational": [
+ {
+ "code": "signingCredential.ocsp.skipped",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.signature"
+ },
+ {
+ "code": "assertion.dataHash.additionalExclusionsPresent",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.hash.data"
+ }
+ ],
+ "failure": []
+ }
+ },
+ "activeManifest": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8",
+ "hash": "b64'KV5vVJVytCxnN0FjRa5AVtYT5Ue+RPHLRVVSJnJ7+QU=",
+ "pad": "b64'"
+ },
+ "claimSignature": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.signature",
+ "hash": "b64'65LVJKaY9a78wEoIcnWHX42CDXikTdwUvnf+krUXmeM=",
+ "pad": "b64'"
+ }
+ }
+ },
+ "claim.v2": {
+ "instanceID": "c641bb90-fb19-a22c-f32c-4d77f1dcb144",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:1096b09b-3554-06fe-7328-478ac267f051/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA Core Generator Library",
+ "version": "893405627:893405627"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'reXvV5FU5mS4CI22lO/WmcO4rWqoOXuuplk7+Vsm5q4="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'+dr5nmes69piwnARv3qtFLW72prSBk0YZPoIR7wlM+E="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.data",
+ "hash": "b64'xab5iN2svJIfwvzQRJOc9RO5GaLIkZOMA/y8HTuWqkY="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "c2c6541a01c1e1f6150dd5605aea363d778f9f",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Media Services 1P ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "OU": "Google System 90291",
+ "CN": "Google Media Processing Services"
+ },
+ "validity": {
+ "notBefore": "2025-12-01T20:34:01+00:00",
+ "notAfter": "2026-11-26T20:34:00+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2026-04-06T19:11:16+00:00",
+ "certificateInfo": {
+ "serialNumber": "b2d5d51f21675e13bc4130aa88bc3be4e0ba1e",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Core Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google Core Time Stamping Authority T9"
+ },
+ "validity": {
+ "notBefore": "2025-09-08T13:48:55+00:00",
+ "notAfter": "2031-09-09T01:48:54+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [],
+ "informational": [],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-04-06T19:37:47.624+00:00"
+ }
+ },
+ {
+ "label": "urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8",
+ "assertions": {
+ "c2pa.hash.multi-asset": {
+ "parts": [
+ {
+ "location": {
+ "byteOffset": 0,
+ "length": 2201646
+ },
+ "hashAssertion": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.hash.data.part",
+ "alg": "",
+ "hash": "b64'f+fgy1+OerCwgBUaf7B+3/YQZO0Rs3OCyV91hEo3hp0=",
+ "pad": "b64'"
+ },
+ "optional": false
+ },
+ {
+ "location": {
+ "byteOffset": 2201646,
+ "length": 104882
+ },
+ "hashAssertion": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.hash.data.part__1",
+ "alg": "",
+ "hash": "b64'6vBDDLKYgbDT6fD+g9+/myvdIv/2NFVpP6VUpIe99E0=",
+ "pad": "b64'"
+ },
+ "optional": true
+ }
+ ],
+ "pad": "b64'AAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+ "pad2": "b64'AA=="
+ },
+ "c2pa.hash.data.part__1": {
+ "alg": "sha256",
+ "hash": "b64'YCKF9pJdtr0cJFTDlZ+m91kmHEJcP/g+MdiTKaczBck=",
+ "pad": "b64'"
+ },
+ "c2pa.hash.data.part": {
+ "exclusions": [
+ {
+ "start": 6,
+ "length": 29033
+ },
+ {
+ "start": 29057,
+ "length": 6687
+ },
+ {
+ "start": 36362,
+ "length": 1159
+ },
+ {
+ "start": 37525,
+ "length": 65458
+ },
+ {
+ "start": 102987,
+ "length": 65458
+ },
+ {
+ "start": 168449,
+ "length": 65458
+ },
+ {
+ "start": 233911,
+ "length": 65458
+ },
+ {
+ "start": 299373,
+ "length": 65458
+ },
+ {
+ "start": 364835,
+ "length": 65458
+ },
+ {
+ "start": 430297,
+ "length": 65458
+ },
+ {
+ "start": 495759,
+ "length": 29380
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'MC2WtOu48zBrMprYlxnm2hKHj2bKI9h64crE6HGfM8Y=",
+ "pad": "b64'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+ "pad2": "b64'"
+ },
+ "c2pa.hash.data": {
+ "exclusions": [
+ {
+ "start": 6,
+ "length": 29033
+ },
+ {
+ "start": 29057,
+ "length": 6687
+ },
+ {
+ "start": 36362,
+ "length": 1159
+ },
+ {
+ "start": 37525,
+ "length": 65458
+ },
+ {
+ "start": 102987,
+ "length": 65458
+ },
+ {
+ "start": 168449,
+ "length": 65458
+ },
+ {
+ "start": 233911,
+ "length": 65458
+ },
+ {
+ "start": 299373,
+ "length": 65458
+ },
+ {
+ "start": 364835,
+ "length": 65458
+ },
+ {
+ "start": 430297,
+ "length": 65458
+ },
+ {
+ "start": 495759,
+ "length": 29380
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'pHJmHV6VE0ZYSue93tkLM+/tRKhmegKkPjTby7eGNA0=",
+ "pad": "b64'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+ "pad2": "b64'"
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.created",
+ "description": "Created by Pixel Camera.",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+ }
+ ]
+ }
+ },
+ "claim.v2": {
+ "instanceID": "66808d7a-4558-121c-751a-4ec0fcfafdb6",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA SDK for Android",
+ "version": "781252796:793865596"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.hash.data.part__1",
+ "hash": "b64'6vBDDLKYgbDT6fD+g9+/myvdIv/2NFVpP6VUpIe99E0="
+ },
+ {
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.hash.data.part",
+ "hash": "b64'f+fgy1+OerCwgBUaf7B+3/YQZO0Rs3OCyV91hEo3hp0="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'Axs3an5HFczmpjJiejh1Z5lKQi2MVSH8g1NVbjSNOjI="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.data",
+ "hash": "b64'xOsMWdfp1/ZlFPCZgj8IFJ93ix2XCG8VutFmkihEjCw="
+ },
+ {
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.hash.multi-asset",
+ "hash": "b64'8osZQ4n42hdQlS/GPQzfZcNgtWus+PhQ8ImQChIP3qk="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "81ce674ee783b652548ba056ef34993be9adee",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Mobile A 1P ICA G3 L1"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Pixel Camera"
+ },
+ "validity": {
+ "notBefore": "2025-08-14T04:44:05+00:00",
+ "notAfter": "2025-11-11T04:44:04+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2025-08-14T05:48:57+00:00",
+ "certificateInfo": {
+ "serialNumber": "801dd6ca0e3a76f622533f629162f7faa703c9",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Pixel Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google",
+ "CN": "Google Pixel Time Stamping Authority"
+ },
+ "validity": {
+ "notBefore": "2025-05-12T23:43:02+00:00",
+ "notAfter": "2032-11-11T08:43:01+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [],
+ "informational": [],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-04-06T19:37:47.623+00:00"
+ }
+ }
+ ],
+ "jsonGenerator": {
+ "name": "c2pa-rs",
+ "version": "0.78.0"
+ },
+ "usedITL": false,
+ "usedTestCerts": false,
+ "_conformanceToolVersion": {
+ "commit": "d209e393a529f8f54e06842aea3216a558b1caa3",
+ "shortCommit": "d209e39",
+ "date": "2026-03-31 12:02:12 -0400",
+ "branch": "HEAD",
+ "generatedAt": "2026-03-31T16:07:04.350Z"
+ }
+}
\ No newline at end of file
diff --git a/src/lib/rubrics/__fixtures__/capture2genai.signals.json b/src/lib/rubrics/__fixtures__/capture2genai.signals.json
new file mode 100644
index 0000000..4269420
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/capture2genai.signals.json
@@ -0,0 +1,75 @@
+{
+ "rubricName": "C2PA Asset Signals Rubric (Local)",
+ "rubricVersion": "1.0.0",
+ "manifests": [
+ {
+ "assertedBy": {
+ "CN": "Google Media Processing Services",
+ "O": "Google LLC",
+ "OU": "Google System 60032"
+ },
+ "mimeType": null,
+ "localInceptions": [
+ {
+ "trait": "inception:signal_fullyGenAIMedia",
+ "reportText": "Contains Fully GenAI Media",
+ "multiple": false
+ }
+ ],
+ "localTransformations": [
+ {
+ "trait": "transformation:signal_editorialAI",
+ "reportText": "Contains Editorial GenAI Transformations",
+ "multiple": false
+ }
+ ],
+ "allActionsIncluded": false,
+ "ingredients": [
+ {
+ "index": 1,
+ "relationship": "inputTo"
+ }
+ ]
+ },
+ {
+ "assertedBy": {
+ "CN": "Google Media Processing Services",
+ "O": "Google LLC",
+ "OU": "Google System 90291"
+ },
+ "mimeType": "image/jpeg",
+ "localInceptions": [],
+ "localTransformations": [
+ {
+ "trait": "transformation:signal_nonEditorial",
+ "reportText": "Contains Non-editorial Transformations",
+ "multiple": false
+ }
+ ],
+ "allActionsIncluded": false,
+ "ingredients": [
+ {
+ "index": 2,
+ "relationship": "parentOf"
+ }
+ ]
+ },
+ {
+ "assertedBy": {
+ "CN": "Pixel Camera",
+ "O": "Google LLC"
+ },
+ "mimeType": "image/jpeg",
+ "localInceptions": [
+ {
+ "trait": "inception:signal_capturedMedia",
+ "reportText": "Contains Captured Media",
+ "multiple": false
+ }
+ ],
+ "localTransformations": [],
+ "allActionsIncluded": false,
+ "ingredients": []
+ }
+ ]
+}
diff --git a/src/lib/rubrics/__fixtures__/capture2genai2v.conformance.json b/src/lib/rubrics/__fixtures__/capture2genai2v.conformance.json
new file mode 100644
index 0000000..548f09d
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/capture2genai2v.conformance.json
@@ -0,0 +1,71 @@
+{
+ "rubricName": "C2PA Asset Conformance 0.1 Spec 2.2 Rubric",
+ "rubricVersion": "0.1.0",
+ "true": {
+ "validation": [
+ {
+ "trait": "active_manifest_urn",
+ "reportText": "Active manifest URN uses standard 'urn:c2pa:' prefix"
+ },
+ {
+ "trait": "inception_action_position",
+ "reportText": "Inception action is correctly positioned as the first item in the first created actions assertion"
+ },
+ {
+ "trait": "ingredient_relationship_values",
+ "reportText": "All ingredient assertions have valid relationship values"
+ },
+ {
+ "trait": "ingredient_v3_no_active_manifest",
+ "reportText": "Ingredient v3 assertions without activeManifest correctly omit validationResults"
+ },
+ {
+ "trait": "no_deprecated_actions",
+ "reportText": "No deprecated actions used"
+ },
+ {
+ "trait": "no_deprecated_assertions",
+ "reportText": "No deprecated standard assertions found"
+ },
+ {
+ "trait": "no_unsupported_assertions",
+ "reportText": "All standard assertions are supported for Spec 2.2"
+ },
+ {
+ "trait": "review_ratings_datasource",
+ "reportText": "No invalid co-occurrence of reviewRatings and human entry dataSource"
+ },
+ {
+ "trait": "trusted_data_present",
+ "reportText": "Validation results are present for trust analysis"
+ },
+ {
+ "trait": "trusted_success",
+ "reportText": "Asset is trusted"
+ },
+ {
+ "trait": "valid_data_present",
+ "reportText": "Validation results are present for integrity analysis"
+ },
+ {
+ "trait": "valid_success",
+ "reportText": "No validation mismatches found"
+ },
+ {
+ "trait": "well_formed_data_present",
+ "reportText": "Validation results are present for structural analysis"
+ },
+ {
+ "trait": "well_formed_success",
+ "reportText": "No structural failures found"
+ },
+ {
+ "trait": "update_manifest_constraints",
+ "reportText": "Update manifest constraints are satisfied (or manifest is not an update manifest)"
+ }
+ ]
+ },
+ "false": {
+ "validation": []
+ }
+}
diff --git a/src/lib/rubrics/__fixtures__/capture2genai2v.json b/src/lib/rubrics/__fixtures__/capture2genai2v.json
new file mode 100644
index 0000000..74d9351
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/capture2genai2v.json
@@ -0,0 +1,911 @@
+{
+ "@context": {
+ "@vocab": "https://contentcredentials.org/crjson",
+ "extras": "https://contentcredentials.org/crjson/extras"
+ },
+ "manifests": [
+ {
+ "label": "urn:c2pa:a3218aaa-91ec-c95d-2fa8-47cca4f5c00a",
+ "assertions": {
+ "c2pa.hash.bmff.v3": {
+ "exclusions": [
+ {
+ "xpath": "/ftyp"
+ },
+ {
+ "xpath": "/uuid",
+ "data": [
+ {
+ "offset": 8,
+ "value": [
+ 216,
+ 254,
+ 195,
+ 214,
+ 27,
+ 14,
+ 72,
+ 60,
+ 146,
+ 151,
+ 88,
+ 40,
+ 135,
+ 126,
+ 196,
+ 129
+ ]
+ }
+ ]
+ },
+ {
+ "xpath": "/mfra"
+ },
+ {
+ "xpath": "/free"
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'C2Nl904mAB3FaKHin5t4W/GfLJWFUgoLNkWfXCzFPTQ=",
+ "name": "BMFF file hash",
+ "pad": "b64'"
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.created",
+ "description": "Created by Google Generative AI.",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia",
+ "parameters": {
+ "ingredients": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'ikvBZJWWN5l69j1+/GwgnCJ8DAvlHKSWiF0ZTnWKAUY=",
+ "pad": "b64'"
+ }
+ ]
+ }
+ }
+ ]
+ },
+ "c2pa.ingredient.v3": {
+ "relationship": "inputTo",
+ "dc:format": "image/png",
+ "validationResults": {
+ "activeManifest": {
+ "success": [
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:763fbc99-427d-a47d-35dc-4fbdeff1f4ef/c2pa.signature"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:763fbc99-427d-a47d-35dc-4fbdeff1f4ef/c2pa.signature"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:763fbc99-427d-a47d-35dc-4fbdeff1f4ef/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:763fbc99-427d-a47d-35dc-4fbdeff1f4ef/c2pa.signature"
+ },
+ {
+ "code": "signingCredential.ocsp.notRevoked",
+ "url": "self#jumbf=/c2pa/urn:c2pa:763fbc99-427d-a47d-35dc-4fbdeff1f4ef/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:763fbc99-427d-a47d-35dc-4fbdeff1f4ef/c2pa.signature"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:763fbc99-427d-a47d-35dc-4fbdeff1f4ef/c2pa.assertions/c2pa.ingredient.v3"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:763fbc99-427d-a47d-35dc-4fbdeff1f4ef/c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:763fbc99-427d-a47d-35dc-4fbdeff1f4ef/c2pa.assertions/c2pa.hash.data"
+ },
+ {
+ "code": "ingredient.claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:763fbc99-427d-a47d-35dc-4fbdeff1f4ef/c2pa.assertions/c2pa.ingredient.v3"
+ },
+ {
+ "code": "assertion.dataHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:763fbc99-427d-a47d-35dc-4fbdeff1f4ef/c2pa.assertions/c2pa.hash.data"
+ }
+ ],
+ "informational": [],
+ "failure": []
+ },
+ "ingredientDeltas": [
+ {
+ "ingredientAssertionURI": "self#jumbf=/c2pa/urn:c2pa:763fbc99-427d-a47d-35dc-4fbdeff1f4ef/c2pa.assertions/c2pa.ingredient.v3",
+ "validationDeltas": {
+ "success": [],
+ "informational": [],
+ "failure": []
+ }
+ },
+ {
+ "ingredientAssertionURI": "self#jumbf=/c2pa/urn:c2pa:1096b09b-3554-06fe-7328-478ac267f051/c2pa.assertions/c2pa.ingredient.v3",
+ "validationDeltas": {
+ "success": [],
+ "informational": [],
+ "failure": []
+ }
+ }
+ ]
+ },
+ "activeManifest": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:763fbc99-427d-a47d-35dc-4fbdeff1f4ef",
+ "hash": "b64'UXFZg3b/ZhKwExJPz48k7sDa3emEx/wHhv+Y764ZdaI=",
+ "pad": "b64'"
+ },
+ "claimSignature": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:763fbc99-427d-a47d-35dc-4fbdeff1f4ef/c2pa.signature",
+ "hash": "b64'Qnk6GR3JSOqQbQqWURc+EM364PPpYYBRMkHvZwtme6Y=",
+ "pad": "b64'"
+ },
+ "description": "Input ingredient 0"
+ }
+ },
+ "claim.v2": {
+ "instanceID": "1a0bb626-3c76-5405-4350-46515cf9f272",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:a3218aaa-91ec-c95d-2fa8-47cca4f5c00a/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA Core Generator Library",
+ "version": "893217790:893217790"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'ikvBZJWWN5l69j1+/GwgnCJ8DAvlHKSWiF0ZTnWKAUY="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'x27kgF2xgmyWWRmpOMLIpAm1S58DsLxzGoPbXjInWCI="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3",
+ "hash": "b64'4UmF87wyeLR5jPmolakHR6m+b6TQ5GmTkROqEByB4r4="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "19e8483a6248fb45b185a31f672b844007ed7d",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Media Services 1P ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "OU": "Google System 98649",
+ "CN": "Google Media Processing Services"
+ },
+ "validity": {
+ "notBefore": "2025-08-15T17:37:52+00:00",
+ "notAfter": "2026-08-10T17:37:51+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2026-04-06T19:13:38+00:00",
+ "certificateInfo": {
+ "serialNumber": "6c26eeedd09cdcec7670d543e6da504e3a9c5e",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Core Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google Core Time Stamping Authority T12"
+ },
+ "validity": {
+ "notBefore": "2025-09-08T13:49:00+00:00",
+ "notAfter": "2031-09-09T01:48:59+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:a3218aaa-91ec-c95d-2fa8-47cca4f5c00a/c2pa.signature",
+ "explanation": "timestamp message digest matched: Google Core Time Stamping Authority T12"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:a3218aaa-91ec-c95d-2fa8-47cca4f5c00a/c2pa.signature",
+ "explanation": "timestamp cert trusted: Google Core Time Stamping Authority T12"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:a3218aaa-91ec-c95d-2fa8-47cca4f5c00a/c2pa.signature",
+ "explanation": "signing certificate trusted, found in System trust anchors"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:a3218aaa-91ec-c95d-2fa8-47cca4f5c00a/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:a3218aaa-91ec-c95d-2fa8-47cca4f5c00a/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:a3218aaa-91ec-c95d-2fa8-47cca4f5c00a/c2pa.assertions/c2pa.ingredient.v3",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:a3218aaa-91ec-c95d-2fa8-47cca4f5c00a/c2pa.assertions/c2pa.actions.v2",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:a3218aaa-91ec-c95d-2fa8-47cca4f5c00a/c2pa.assertions/c2pa.hash.bmff.v3",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3"
+ },
+ {
+ "code": "assertion.bmffHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:a3218aaa-91ec-c95d-2fa8-47cca4f5c00a/c2pa.assertions/c2pa.hash.bmff.v3",
+ "explanation": "BMFF hash valid"
+ }
+ ],
+ "informational": [
+ {
+ "code": "signingCredential.ocsp.notRevoked",
+ "url": "self#jumbf=/c2pa/urn:c2pa:a3218aaa-91ec-c95d-2fa8-47cca4f5c00a/c2pa.signature",
+ "explanation": "signing cert not revoked: 577753258235401352320643432677519988913335677"
+ }
+ ],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-04-06T19:41:06.912+00:00"
+ }
+ },
+ {
+ "label": "urn:c2pa:763fbc99-427d-a47d-35dc-4fbdeff1f4ef",
+ "assertions": {
+ "c2pa.hash.data": {
+ "exclusions": [
+ {
+ "start": 33,
+ "length": 23121
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'R9PP+6cdxwGtKUTmslZ/vx6ILS8x4VdJsATtH5odtSo=",
+ "pad": "b64'AAAAAAAAAAAAAAAAAA=="
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.created",
+ "description": "Created by Google Generative AI.",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia",
+ "parameters": {
+ "ingredients": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'/PqDQECJt0CU3rLGrvT9jSkaFqDuctFYfgoejBZTeZw=",
+ "pad": "b64'"
+ }
+ ]
+ }
+ },
+ {
+ "action": "c2pa.edited",
+ "description": "Applied imperceptible SynthID watermark.",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"
+ }
+ ]
+ },
+ "c2pa.ingredient.v3": {
+ "relationship": "inputTo",
+ "dc:format": "image/jpeg",
+ "validationResults": {
+ "activeManifest": {
+ "success": [
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:1096b09b-3554-06fe-7328-478ac267f051/c2pa.signature"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:1096b09b-3554-06fe-7328-478ac267f051/c2pa.signature"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:1096b09b-3554-06fe-7328-478ac267f051/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:1096b09b-3554-06fe-7328-478ac267f051/c2pa.signature"
+ },
+ {
+ "code": "signingCredential.ocsp.notRevoked",
+ "url": "self#jumbf=/c2pa/urn:c2pa:1096b09b-3554-06fe-7328-478ac267f051/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:1096b09b-3554-06fe-7328-478ac267f051/c2pa.signature"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:1096b09b-3554-06fe-7328-478ac267f051/c2pa.assertions/c2pa.ingredient.v3"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:1096b09b-3554-06fe-7328-478ac267f051/c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:1096b09b-3554-06fe-7328-478ac267f051/c2pa.assertions/c2pa.hash.data"
+ },
+ {
+ "code": "ingredient.claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:1096b09b-3554-06fe-7328-478ac267f051/c2pa.assertions/c2pa.ingredient.v3"
+ },
+ {
+ "code": "assertion.dataHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:1096b09b-3554-06fe-7328-478ac267f051/c2pa.assertions/c2pa.hash.data"
+ }
+ ],
+ "informational": [],
+ "failure": []
+ },
+ "ingredientDeltas": [
+ {
+ "ingredientAssertionURI": "self#jumbf=/c2pa/urn:c2pa:1096b09b-3554-06fe-7328-478ac267f051/c2pa.assertions/c2pa.ingredient.v3",
+ "validationDeltas": {
+ "success": [],
+ "informational": [],
+ "failure": []
+ }
+ }
+ ]
+ },
+ "activeManifest": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:1096b09b-3554-06fe-7328-478ac267f051",
+ "hash": "b64'wE9Tl0MD4vycmSEplOdrfn/BX74Odzj9inxUmeVXlYU=",
+ "pad": "b64'"
+ },
+ "claimSignature": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:1096b09b-3554-06fe-7328-478ac267f051/c2pa.signature",
+ "hash": "b64'xGf+jYOuDbGKLO2RS1ir4BBIpRCQ9mDO8T5D+qh3vNY=",
+ "pad": "b64'"
+ },
+ "description": "Input ingredient 0"
+ }
+ },
+ "claim.v2": {
+ "instanceID": "e1598e1b-3150-a3ee-bf46-4fc480f892a3",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:763fbc99-427d-a47d-35dc-4fbdeff1f4ef/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA Core Generator Library",
+ "version": "894052442:894052442"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'/PqDQECJt0CU3rLGrvT9jSkaFqDuctFYfgoejBZTeZw="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'rVSfAMu2v85r/Y24M5UdBCv+RY01vXoLj14SIyAH3UY="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.data",
+ "hash": "b64'xf6ozMV+ZsiCZo/jrr9Zch1rJsxHjtEYF0HTftOWo+A="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "9eaf156281a94902165b48ff58a96d24981ece",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Media Services 1P ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "OU": "Google System 60032",
+ "CN": "Google Media Processing Services"
+ },
+ "validity": {
+ "notBefore": "2026-02-17T15:17:12+00:00",
+ "notAfter": "2027-02-12T15:17:11+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2026-04-06T19:11:54+00:00",
+ "certificateInfo": {
+ "serialNumber": "a3e6ce9b0e2d6c0443cb71902c6d8f891dd17c",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Core Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google Core Time Stamping Authority T8"
+ },
+ "validity": {
+ "notBefore": "2025-09-08T13:48:53+00:00",
+ "notAfter": "2031-09-09T01:48:52+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [],
+ "informational": [],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-04-06T19:41:06.915+00:00"
+ }
+ },
+ {
+ "label": "urn:c2pa:1096b09b-3554-06fe-7328-478ac267f051",
+ "assertions": {
+ "c2pa.hash.data": {
+ "exclusions": [
+ {
+ "start": 937,
+ "length": 14913
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'peZi6rphpMN/NMY2e+arqniEe07mEfzeAiG5IHhESOo=",
+ "pad": "b64'AAAAAAAAAAAAAAAA"
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.opened",
+ "description": "Opened by Google",
+ "parameters": {
+ "ingredients": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'reXvV5FU5mS4CI22lO/WmcO4rWqoOXuuplk7+Vsm5q4=",
+ "pad": "b64'"
+ }
+ ]
+ }
+ },
+ {
+ "action": "c2pa.transcoded",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/algorithmicallyEnhanced",
+ "parameters": {
+ "ingredients": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'reXvV5FU5mS4CI22lO/WmcO4rWqoOXuuplk7+Vsm5q4=",
+ "pad": "b64'"
+ }
+ ]
+ }
+ }
+ ]
+ },
+ "c2pa.ingredient.v3": {
+ "relationship": "parentOf",
+ "dc:format": "image/jpeg",
+ "validationResults": {
+ "activeManifest": {
+ "success": [
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.signature"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.signature"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.signature"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.hash.data.part__1"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.hash.data.part"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.hash.data"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.hash.multi-asset"
+ },
+ {
+ "code": "assertion.dataHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.hash.data"
+ }
+ ],
+ "informational": [
+ {
+ "code": "signingCredential.ocsp.skipped",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.signature"
+ },
+ {
+ "code": "assertion.dataHash.additionalExclusionsPresent",
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.hash.data"
+ }
+ ],
+ "failure": []
+ }
+ },
+ "activeManifest": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8",
+ "hash": "b64'KV5vVJVytCxnN0FjRa5AVtYT5Ue+RPHLRVVSJnJ7+QU=",
+ "pad": "b64'"
+ },
+ "claimSignature": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.signature",
+ "hash": "b64'65LVJKaY9a78wEoIcnWHX42CDXikTdwUvnf+krUXmeM=",
+ "pad": "b64'"
+ }
+ }
+ },
+ "claim.v2": {
+ "instanceID": "c641bb90-fb19-a22c-f32c-4d77f1dcb144",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:1096b09b-3554-06fe-7328-478ac267f051/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA Core Generator Library",
+ "version": "893405627:893405627"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'reXvV5FU5mS4CI22lO/WmcO4rWqoOXuuplk7+Vsm5q4="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'+dr5nmes69piwnARv3qtFLW72prSBk0YZPoIR7wlM+E="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.data",
+ "hash": "b64'xab5iN2svJIfwvzQRJOc9RO5GaLIkZOMA/y8HTuWqkY="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "c2c6541a01c1e1f6150dd5605aea363d778f9f",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Media Services 1P ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "OU": "Google System 90291",
+ "CN": "Google Media Processing Services"
+ },
+ "validity": {
+ "notBefore": "2025-12-01T20:34:01+00:00",
+ "notAfter": "2026-11-26T20:34:00+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2026-04-06T19:11:16+00:00",
+ "certificateInfo": {
+ "serialNumber": "b2d5d51f21675e13bc4130aa88bc3be4e0ba1e",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Core Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google Core Time Stamping Authority T9"
+ },
+ "validity": {
+ "notBefore": "2025-09-08T13:48:55+00:00",
+ "notAfter": "2031-09-09T01:48:54+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [],
+ "informational": [],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-04-06T19:41:06.914+00:00"
+ }
+ },
+ {
+ "label": "urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8",
+ "assertions": {
+ "c2pa.hash.multi-asset": {
+ "parts": [
+ {
+ "location": {
+ "byteOffset": 0,
+ "length": 2201646
+ },
+ "hashAssertion": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.hash.data.part",
+ "alg": "",
+ "hash": "b64'f+fgy1+OerCwgBUaf7B+3/YQZO0Rs3OCyV91hEo3hp0=",
+ "pad": "b64'"
+ },
+ "optional": false
+ },
+ {
+ "location": {
+ "byteOffset": 2201646,
+ "length": 104882
+ },
+ "hashAssertion": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.hash.data.part__1",
+ "alg": "",
+ "hash": "b64'6vBDDLKYgbDT6fD+g9+/myvdIv/2NFVpP6VUpIe99E0=",
+ "pad": "b64'"
+ },
+ "optional": true
+ }
+ ],
+ "pad": "b64'AAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+ "pad2": "b64'AA=="
+ },
+ "c2pa.hash.data.part__1": {
+ "alg": "sha256",
+ "hash": "b64'YCKF9pJdtr0cJFTDlZ+m91kmHEJcP/g+MdiTKaczBck=",
+ "pad": "b64'"
+ },
+ "c2pa.hash.data.part": {
+ "exclusions": [
+ {
+ "start": 6,
+ "length": 29033
+ },
+ {
+ "start": 29057,
+ "length": 6687
+ },
+ {
+ "start": 36362,
+ "length": 1159
+ },
+ {
+ "start": 37525,
+ "length": 65458
+ },
+ {
+ "start": 102987,
+ "length": 65458
+ },
+ {
+ "start": 168449,
+ "length": 65458
+ },
+ {
+ "start": 233911,
+ "length": 65458
+ },
+ {
+ "start": 299373,
+ "length": 65458
+ },
+ {
+ "start": 364835,
+ "length": 65458
+ },
+ {
+ "start": 430297,
+ "length": 65458
+ },
+ {
+ "start": 495759,
+ "length": 29380
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'MC2WtOu48zBrMprYlxnm2hKHj2bKI9h64crE6HGfM8Y=",
+ "pad": "b64'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+ "pad2": "b64'"
+ },
+ "c2pa.hash.data": {
+ "exclusions": [
+ {
+ "start": 6,
+ "length": 29033
+ },
+ {
+ "start": 29057,
+ "length": 6687
+ },
+ {
+ "start": 36362,
+ "length": 1159
+ },
+ {
+ "start": 37525,
+ "length": 65458
+ },
+ {
+ "start": 102987,
+ "length": 65458
+ },
+ {
+ "start": 168449,
+ "length": 65458
+ },
+ {
+ "start": 233911,
+ "length": 65458
+ },
+ {
+ "start": 299373,
+ "length": 65458
+ },
+ {
+ "start": 364835,
+ "length": 65458
+ },
+ {
+ "start": 430297,
+ "length": 65458
+ },
+ {
+ "start": 495759,
+ "length": 29380
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'pHJmHV6VE0ZYSue93tkLM+/tRKhmegKkPjTby7eGNA0=",
+ "pad": "b64'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+ "pad2": "b64'"
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.created",
+ "description": "Created by Pixel Camera.",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/computationalCapture"
+ }
+ ]
+ }
+ },
+ "claim.v2": {
+ "instanceID": "66808d7a-4558-121c-751a-4ec0fcfafdb6",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA SDK for Android",
+ "version": "781252796:793865596"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.hash.data.part__1",
+ "hash": "b64'6vBDDLKYgbDT6fD+g9+/myvdIv/2NFVpP6VUpIe99E0="
+ },
+ {
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.hash.data.part",
+ "hash": "b64'f+fgy1+OerCwgBUaf7B+3/YQZO0Rs3OCyV91hEo3hp0="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'Axs3an5HFczmpjJiejh1Z5lKQi2MVSH8g1NVbjSNOjI="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.data",
+ "hash": "b64'xOsMWdfp1/ZlFPCZgj8IFJ93ix2XCG8VutFmkihEjCw="
+ },
+ {
+ "url": "self#jumbf=/c2pa/urn:c2pa:b8c1b157-d9b7-1fba-595f-4b5d5beb2ef8/c2pa.assertions/c2pa.hash.multi-asset",
+ "hash": "b64'8osZQ4n42hdQlS/GPQzfZcNgtWus+PhQ8ImQChIP3qk="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "81ce674ee783b652548ba056ef34993be9adee",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Mobile A 1P ICA G3 L1"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Pixel Camera"
+ },
+ "validity": {
+ "notBefore": "2025-08-14T04:44:05+00:00",
+ "notAfter": "2025-11-11T04:44:04+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2025-08-14T05:48:57+00:00",
+ "certificateInfo": {
+ "serialNumber": "801dd6ca0e3a76f622533f629162f7faa703c9",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Pixel Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google",
+ "CN": "Google Pixel Time Stamping Authority"
+ },
+ "validity": {
+ "notBefore": "2025-05-12T23:43:02+00:00",
+ "notAfter": "2032-11-11T08:43:01+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [],
+ "informational": [],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-04-06T19:41:06.913+00:00"
+ }
+ }
+ ],
+ "jsonGenerator": {
+ "name": "c2pa-rs",
+ "version": "0.78.0"
+ },
+ "usedITL": false,
+ "usedTestCerts": false,
+ "_conformanceToolVersion": {
+ "commit": "d209e393a529f8f54e06842aea3216a558b1caa3",
+ "shortCommit": "d209e39",
+ "date": "2026-03-31 12:02:12 -0400",
+ "branch": "HEAD",
+ "generatedAt": "2026-03-31T16:07:04.350Z"
+ }
+}
\ No newline at end of file
diff --git a/src/lib/rubrics/__fixtures__/capture2genai2v.signals.json b/src/lib/rubrics/__fixtures__/capture2genai2v.signals.json
new file mode 100644
index 0000000..83df0d3
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/capture2genai2v.signals.json
@@ -0,0 +1,98 @@
+{
+ "rubricName": "C2PA Asset Signals Rubric (Local)",
+ "rubricVersion": "1.0.0",
+ "manifests": [
+ {
+ "assertedBy": {
+ "CN": "Google Media Processing Services",
+ "O": "Google LLC",
+ "OU": "Google System 98649"
+ },
+ "mimeType": null,
+ "localInceptions": [
+ {
+ "trait": "inception:signal_fullyGenAIMedia",
+ "reportText": "Contains Fully GenAI Media",
+ "multiple": false
+ }
+ ],
+ "localTransformations": [],
+ "allActionsIncluded": false,
+ "ingredients": [
+ {
+ "index": 1,
+ "relationship": "inputTo"
+ }
+ ]
+ },
+ {
+ "assertedBy": {
+ "CN": "Google Media Processing Services",
+ "O": "Google LLC",
+ "OU": "Google System 60032"
+ },
+ "mimeType": "image/png",
+ "localInceptions": [
+ {
+ "trait": "inception:signal_fullyGenAIMedia",
+ "reportText": "Contains Fully GenAI Media",
+ "multiple": false
+ }
+ ],
+ "localTransformations": [
+ {
+ "trait": "transformation:signal_editorialAI",
+ "reportText": "Contains Editorial GenAI Transformations",
+ "multiple": false
+ }
+ ],
+ "allActionsIncluded": false,
+ "ingredients": [
+ {
+ "index": 2,
+ "relationship": "inputTo"
+ }
+ ]
+ },
+ {
+ "assertedBy": {
+ "CN": "Google Media Processing Services",
+ "O": "Google LLC",
+ "OU": "Google System 90291"
+ },
+ "mimeType": "image/jpeg",
+ "localInceptions": [],
+ "localTransformations": [
+ {
+ "trait": "transformation:signal_nonEditorial",
+ "reportText": "Contains Non-editorial Transformations",
+ "multiple": false
+ }
+ ],
+ "allActionsIncluded": false,
+ "ingredients": [
+ {
+ "index": 3,
+ "relationship": "parentOf"
+ }
+ ]
+ },
+ {
+ "assertedBy": {
+ "CN": "Pixel Camera",
+ "O": "Google LLC"
+ },
+ "mimeType": "image/jpeg",
+ "localInceptions": [
+ {
+ "trait": "inception:signal_capturedMedia",
+ "reportText": "Contains Captured Media",
+ "multiple": false
+ }
+ ],
+ "localTransformations": [],
+ "allActionsIncluded": false,
+ "ingredients": []
+ }
+ ]
+}
diff --git a/src/lib/rubrics/__fixtures__/gathered-assertions-test.conformance.json b/src/lib/rubrics/__fixtures__/gathered-assertions-test.conformance.json
new file mode 100644
index 0000000..0836d4a
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/gathered-assertions-test.conformance.json
@@ -0,0 +1,72 @@
+{
+ "rubricName": "C2PA Asset Conformance 0.1 Spec 2.2 Rubric",
+ "rubricVersion": "0.1.0",
+ "true": {
+ "validation": [
+ {
+ "trait": "active_manifest_urn",
+ "reportText": "Active manifest URN uses standard 'urn:c2pa:' prefix"
+ },
+ {
+ "trait": "ingredient_relationship_values",
+ "reportText": "All ingredient assertions have valid relationship values"
+ },
+ {
+ "trait": "ingredient_v3_no_active_manifest",
+ "reportText": "Ingredient v3 assertions without activeManifest correctly omit validationResults"
+ },
+ {
+ "trait": "no_deprecated_actions",
+ "reportText": "No deprecated actions used"
+ },
+ {
+ "trait": "no_deprecated_assertions",
+ "reportText": "No deprecated standard assertions found"
+ },
+ {
+ "trait": "no_unsupported_assertions",
+ "reportText": "All standard assertions are supported for Spec 2.2"
+ },
+ {
+ "trait": "review_ratings_datasource",
+ "reportText": "No invalid co-occurrence of reviewRatings and human entry dataSource"
+ },
+ {
+ "trait": "trusted_data_present",
+ "reportText": "Validation results are present for trust analysis"
+ },
+ {
+ "trait": "trusted_success",
+ "reportText": "Asset is trusted"
+ },
+ {
+ "trait": "update_manifest_constraints",
+ "reportText": "Update manifest constraints are satisfied (or manifest is not an update manifest)"
+ },
+ {
+ "trait": "valid_data_present",
+ "reportText": "Validation results are present for integrity analysis"
+ },
+ {
+ "trait": "valid_success",
+ "reportText": "No validation mismatches found"
+ },
+ {
+ "trait": "well_formed_data_present",
+ "reportText": "Validation results are present for structural analysis"
+ },
+ {
+ "trait": "well_formed_success",
+ "reportText": "No structural failures found"
+ }
+ ]
+ },
+ "false": {
+ "validation": [
+ {
+ "trait": "inception_action_position",
+ "reportText": "Inception action is not in the first actions assertion of created_assertions (or first item is not an actions assertion)"
+ }
+ ]
+ }
+}
diff --git a/src/lib/rubrics/__fixtures__/gathered-assertions-test.json b/src/lib/rubrics/__fixtures__/gathered-assertions-test.json
new file mode 100644
index 0000000..d5ed2ac
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/gathered-assertions-test.json
@@ -0,0 +1,462 @@
+{
+ "@context": {
+ "@vocab": "https://contentcredentials.org/crjson",
+ "extras": "https://contentcredentials.org/crjson/extras"
+ },
+ "manifests": [
+ {
+ "label": "urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255",
+ "assertions": {
+ "c2pa.hash.multi-asset": {
+ "parts": [
+ {
+ "location": {
+ "byteOffset": 0,
+ "length": 4920752
+ },
+ "hashAssertion": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.assertions/c2pa.hash.data.part",
+ "alg": "",
+ "hash": "b64'y0kpFXeDy86udKXF+kj3j8Gc7uRjUfLeOn3T1IjMeik=",
+ "pad": "b64'"
+ },
+ "optional": false
+ },
+ {
+ "location": {
+ "byteOffset": 4920752,
+ "length": 83067
+ },
+ "hashAssertion": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.assertions/c2pa.hash.data.part__1",
+ "alg": "",
+ "hash": "b64'9hj61bEFiY955OMGx0bMnHus9uo+OXPYumvu9SlgPqs=",
+ "pad": "b64'"
+ },
+ "optional": true
+ }
+ ],
+ "pad": "b64'AAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+ "pad2": "b64'AA=="
+ },
+ "c2pa.hash.data.part__1": {
+ "alg": "sha256",
+ "hash": "b64'q1SZYPsEms/e0tD3k3XGgBgFqPy7O5VVEVDZRCYeHuQ=",
+ "pad": "b64'"
+ },
+ "c2pa.hash.data.part": {
+ "exclusions": [
+ {
+ "start": 6,
+ "length": 1019
+ },
+ {
+ "start": 1043,
+ "length": 7840
+ },
+ {
+ "start": 9501,
+ "length": 1063
+ },
+ {
+ "start": 10568,
+ "length": 65458
+ },
+ {
+ "start": 76030,
+ "length": 65458
+ },
+ {
+ "start": 141492,
+ "length": 65458
+ },
+ {
+ "start": 206954,
+ "length": 65458
+ },
+ {
+ "start": 272416,
+ "length": 65458
+ },
+ {
+ "start": 337878,
+ "length": 65458
+ },
+ {
+ "start": 403340,
+ "length": 65458
+ },
+ {
+ "start": 468802,
+ "length": 65458
+ },
+ {
+ "start": 534264,
+ "length": 65458
+ },
+ {
+ "start": 599726,
+ "length": 65458
+ },
+ {
+ "start": 665188,
+ "length": 65458
+ },
+ {
+ "start": 730650,
+ "length": 65458
+ },
+ {
+ "start": 796112,
+ "length": 65458
+ },
+ {
+ "start": 861574,
+ "length": 65458
+ },
+ {
+ "start": 927036,
+ "length": 65458
+ },
+ {
+ "start": 992498,
+ "length": 65458
+ },
+ {
+ "start": 1057960,
+ "length": 65458
+ },
+ {
+ "start": 1123422,
+ "length": 65458
+ },
+ {
+ "start": 1188884,
+ "length": 65458
+ },
+ {
+ "start": 1254346,
+ "length": 65458
+ },
+ {
+ "start": 1319808,
+ "length": 65458
+ },
+ {
+ "start": 1385270,
+ "length": 65458
+ },
+ {
+ "start": 1450732,
+ "length": 65458
+ },
+ {
+ "start": 1516194,
+ "length": 65458
+ },
+ {
+ "start": 1581656,
+ "length": 65458
+ },
+ {
+ "start": 1647118,
+ "length": 37405
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'caEich5nUouL2TIbXXRBm71EEizfj/QpXjRB8YvtN6o=",
+ "pad": "b64'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+ "pad2": "b64'"
+ },
+ "c2pa.hash.data": {
+ "exclusions": [
+ {
+ "start": 6,
+ "length": 1019
+ },
+ {
+ "start": 1043,
+ "length": 7840
+ },
+ {
+ "start": 9501,
+ "length": 1063
+ },
+ {
+ "start": 10568,
+ "length": 65458
+ },
+ {
+ "start": 76030,
+ "length": 65458
+ },
+ {
+ "start": 141492,
+ "length": 65458
+ },
+ {
+ "start": 206954,
+ "length": 65458
+ },
+ {
+ "start": 272416,
+ "length": 65458
+ },
+ {
+ "start": 337878,
+ "length": 65458
+ },
+ {
+ "start": 403340,
+ "length": 65458
+ },
+ {
+ "start": 468802,
+ "length": 65458
+ },
+ {
+ "start": 534264,
+ "length": 65458
+ },
+ {
+ "start": 599726,
+ "length": 65458
+ },
+ {
+ "start": 665188,
+ "length": 65458
+ },
+ {
+ "start": 730650,
+ "length": 65458
+ },
+ {
+ "start": 796112,
+ "length": 65458
+ },
+ {
+ "start": 861574,
+ "length": 65458
+ },
+ {
+ "start": 927036,
+ "length": 65458
+ },
+ {
+ "start": 992498,
+ "length": 65458
+ },
+ {
+ "start": 1057960,
+ "length": 65458
+ },
+ {
+ "start": 1123422,
+ "length": 65458
+ },
+ {
+ "start": 1188884,
+ "length": 65458
+ },
+ {
+ "start": 1254346,
+ "length": 65458
+ },
+ {
+ "start": 1319808,
+ "length": 65458
+ },
+ {
+ "start": 1385270,
+ "length": 65458
+ },
+ {
+ "start": 1450732,
+ "length": 65458
+ },
+ {
+ "start": 1516194,
+ "length": 65458
+ },
+ {
+ "start": 1581656,
+ "length": 65458
+ },
+ {
+ "start": 1647118,
+ "length": 37405
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'NmSnOIEpWA7upBObXvx80VvNYbUPqJiwhGB3Di2BVaI=",
+ "pad": "b64'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+ "pad2": "b64'"
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.created",
+ "description": "Created by Pixel Camera.",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/compositeCapture"
+ }
+ ]
+ }
+ },
+ "claim.v2": {
+ "instanceID": "a766bf25-d150-664e-85b4-45cae2cb8737",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA SDK for Android",
+ "version": "781252796:793865596"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.assertions/c2pa.hash.multi-asset",
+ "hash": "b64'UMaw/K9C0CkbpdBnDbkS/GkIdIYYKkoP1iTge8Xieuo="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.data",
+ "hash": "b64'kpFvIuZxaDgFDgnaGmwl1sIrgXstpMhfJzimgPPk6ic="
+ },
+ {
+ "url": "self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.assertions/c2pa.hash.data.part",
+ "hash": "b64'y0kpFXeDy86udKXF+kj3j8Gc7uRjUfLeOn3T1IjMeik="
+ },
+ {
+ "url": "self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.assertions/c2pa.hash.data.part__1",
+ "hash": "b64'9hj61bEFiY955OMGx0bMnHus9uo+OXPYumvu9SlgPqs="
+ }
+ ],
+ "gathered_assertions": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'2dgXjElqRv/Bpvv0Bu54W9pi4Ua+S2zIfVYmEIEtUn0="
+ }
+ ]
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "b0e50c36040555550a8780d3d6c78494bf8652",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Mobile A 1P ICA G3 L1"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Pixel Camera"
+ },
+ "validity": {
+ "notBefore": "2025-08-14T04:44:09+00:00",
+ "notAfter": "2025-11-11T04:44:08+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2025-08-14T05:51:25+00:00",
+ "certificateInfo": {
+ "serialNumber": "801dd6ca0e3a76f622533f629162f7faa703c9",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Pixel Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google",
+ "CN": "Google Pixel Time Stamping Authority"
+ },
+ "validity": {
+ "notBefore": "2025-05-12T23:43:02+00:00",
+ "notAfter": "2032-11-11T08:43:01+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.signature",
+ "explanation": "timestamp message digest matched: Google Pixel Time Stamping Authority"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.signature",
+ "explanation": "timestamp cert trusted: Google Pixel Time Stamping Authority"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.signature",
+ "explanation": "signing certificate trusted, found in System trust anchors"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.assertions/c2pa.hash.multi-asset",
+ "explanation": "hashed uri matched: self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.assertions/c2pa.hash.multi-asset"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.assertions/c2pa.hash.data",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.hash.data"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.assertions/c2pa.actions.v2",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.assertions/c2pa.hash.data.part",
+ "explanation": "hashed uri matched: self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.assertions/c2pa.hash.data.part"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.assertions/c2pa.hash.data.part__1",
+ "explanation": "hashed uri matched: self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.assertions/c2pa.hash.data.part__1"
+ },
+ {
+ "code": "assertion.dataHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.assertions/c2pa.hash.data",
+ "explanation": "data hash valid"
+ }
+ ],
+ "informational": [
+ {
+ "code": "assertion.dataHash.additionalExclusionsPresent",
+ "url": "self#jumbf=/c2pa/urn:c2pa:bbc6882f-c24f-2263-4516-43bec19c9255/c2pa.assertions/c2pa.hash.data",
+ "explanation": "extra data hash exclusions found"
+ }
+ ],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-03-31T06:07:04.965+00:00"
+ }
+ }
+ ],
+ "jsonGenerator": {
+ "name": "c2pa-rs",
+ "version": "0.78.0"
+ },
+ "usedITL": false,
+ "usedTestCerts": false,
+ "_conformanceToolVersion": {
+ "commit": "d291fb884e02665d169a38928392c2710f6f0953",
+ "shortCommit": "d291fb8",
+ "date": "2026-03-19 20:11:48 -0400",
+ "branch": "HEAD",
+ "generatedAt": "2026-03-23T18:42:00.448Z"
+ }
+}
\ No newline at end of file
diff --git a/src/lib/rubrics/__fixtures__/gathered-assertions-test.signals.json b/src/lib/rubrics/__fixtures__/gathered-assertions-test.signals.json
new file mode 100644
index 0000000..7caaf2f
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/gathered-assertions-test.signals.json
@@ -0,0 +1,17 @@
+{
+ "rubricName": "C2PA Asset Signals Rubric (Local)",
+ "rubricVersion": "1.0.0",
+ "manifests": [
+ {
+ "assertedBy": {
+ "CN": "Pixel Camera",
+ "O": "Google LLC"
+ },
+ "mimeType": null,
+ "localInceptions": [],
+ "localTransformations": [],
+ "allActionsIncluded": false,
+ "ingredients": []
+ }
+ ]
+}
diff --git a/src/lib/rubrics/__fixtures__/i2v.conformance.json b/src/lib/rubrics/__fixtures__/i2v.conformance.json
new file mode 100644
index 0000000..548f09d
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/i2v.conformance.json
@@ -0,0 +1,71 @@
+{
+ "rubricName": "C2PA Asset Conformance 0.1 Spec 2.2 Rubric",
+ "rubricVersion": "0.1.0",
+ "true": {
+ "validation": [
+ {
+ "trait": "active_manifest_urn",
+ "reportText": "Active manifest URN uses standard 'urn:c2pa:' prefix"
+ },
+ {
+ "trait": "inception_action_position",
+ "reportText": "Inception action is correctly positioned as the first item in the first created actions assertion"
+ },
+ {
+ "trait": "ingredient_relationship_values",
+ "reportText": "All ingredient assertions have valid relationship values"
+ },
+ {
+ "trait": "ingredient_v3_no_active_manifest",
+ "reportText": "Ingredient v3 assertions without activeManifest correctly omit validationResults"
+ },
+ {
+ "trait": "no_deprecated_actions",
+ "reportText": "No deprecated actions used"
+ },
+ {
+ "trait": "no_deprecated_assertions",
+ "reportText": "No deprecated standard assertions found"
+ },
+ {
+ "trait": "no_unsupported_assertions",
+ "reportText": "All standard assertions are supported for Spec 2.2"
+ },
+ {
+ "trait": "review_ratings_datasource",
+ "reportText": "No invalid co-occurrence of reviewRatings and human entry dataSource"
+ },
+ {
+ "trait": "trusted_data_present",
+ "reportText": "Validation results are present for trust analysis"
+ },
+ {
+ "trait": "trusted_success",
+ "reportText": "Asset is trusted"
+ },
+ {
+ "trait": "valid_data_present",
+ "reportText": "Validation results are present for integrity analysis"
+ },
+ {
+ "trait": "valid_success",
+ "reportText": "No validation mismatches found"
+ },
+ {
+ "trait": "well_formed_data_present",
+ "reportText": "Validation results are present for structural analysis"
+ },
+ {
+ "trait": "well_formed_success",
+ "reportText": "No structural failures found"
+ },
+ {
+ "trait": "update_manifest_constraints",
+ "reportText": "Update manifest constraints are satisfied (or manifest is not an update manifest)"
+ }
+ ]
+ },
+ "false": {
+ "validation": []
+ }
+}
diff --git a/src/lib/rubrics/__fixtures__/i2v.json b/src/lib/rubrics/__fixtures__/i2v.json
new file mode 100644
index 0000000..0d8424b
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/i2v.json
@@ -0,0 +1,720 @@
+{
+ "@context": {
+ "@vocab": "https://contentcredentials.org/crjson",
+ "extras": "https://contentcredentials.org/crjson/extras"
+ },
+ "manifests": [
+ {
+ "label": "urn:c2pa:c4718f88-1bde-39ed-8835-4e22b2eff306",
+ "assertions": {
+ "c2pa.hash.bmff.v3": {
+ "exclusions": [
+ {
+ "xpath": "/ftyp"
+ },
+ {
+ "xpath": "/uuid",
+ "data": [
+ {
+ "offset": 8,
+ "value": [
+ 216,
+ 254,
+ 195,
+ 214,
+ 27,
+ 14,
+ 72,
+ 60,
+ 146,
+ 151,
+ 88,
+ 40,
+ 135,
+ 126,
+ 196,
+ 129
+ ]
+ }
+ ]
+ },
+ {
+ "xpath": "/mfra"
+ },
+ {
+ "xpath": "/free"
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'YEx/Eg1d410UxfiSSTShgM39jXS5ugtlEDohWb9UnMI=",
+ "name": "BMFF file hash",
+ "pad": "b64'"
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.created",
+ "description": "Created by Google Generative AI.",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia",
+ "parameters": {
+ "ingredients": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'uj+TmpMCaMsNTa+BJitjrdcLyuE/RFLbeLzZj9of4Xs=",
+ "pad": "b64'"
+ }
+ ]
+ }
+ }
+ ]
+ },
+ "c2pa.ingredient.v3": {
+ "relationship": "inputTo",
+ "validationResults": {
+ "activeManifest": {
+ "success": [
+ {
+ "code": "signingCredential.ocsp.notRevoked",
+ "url": "self#jumbf=/c2pa/urn:c2pa:e53c99d0-84bf-6456-386b-4672fd90758a/c2pa.signature"
+ },
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:e53c99d0-84bf-6456-386b-4672fd90758a/c2pa.signature"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:e53c99d0-84bf-6456-386b-4672fd90758a/c2pa.signature"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:e53c99d0-84bf-6456-386b-4672fd90758a/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:e53c99d0-84bf-6456-386b-4672fd90758a/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:e53c99d0-84bf-6456-386b-4672fd90758a/c2pa.signature"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:e53c99d0-84bf-6456-386b-4672fd90758a/c2pa.assertions/c2pa.ingredient.v3"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:e53c99d0-84bf-6456-386b-4672fd90758a/c2pa.assertions/c2pa.ingredient.v3__1"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:e53c99d0-84bf-6456-386b-4672fd90758a/c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:e53c99d0-84bf-6456-386b-4672fd90758a/c2pa.assertions/c2pa.hash.data"
+ },
+ {
+ "code": "ingredient.claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:e53c99d0-84bf-6456-386b-4672fd90758a/c2pa.assertions/c2pa.ingredient.v3"
+ },
+ {
+ "code": "ingredient.claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:e53c99d0-84bf-6456-386b-4672fd90758a/c2pa.assertions/c2pa.ingredient.v3__1"
+ },
+ {
+ "code": "assertion.dataHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:e53c99d0-84bf-6456-386b-4672fd90758a/c2pa.assertions/c2pa.hash.data"
+ }
+ ],
+ "informational": [],
+ "failure": []
+ },
+ "ingredientDeltas": [
+ {
+ "ingredientAssertionURI": "self#jumbf=/c2pa/urn:c2pa:e53c99d0-84bf-6456-386b-4672fd90758a/c2pa.assertions/c2pa.ingredient.v3",
+ "validationDeltas": {
+ "success": [],
+ "informational": [],
+ "failure": []
+ }
+ },
+ {
+ "ingredientAssertionURI": "self#jumbf=/c2pa/urn:c2pa:e53c99d0-84bf-6456-386b-4672fd90758a/c2pa.assertions/c2pa.ingredient.v3__1",
+ "validationDeltas": {
+ "success": [],
+ "informational": [],
+ "failure": []
+ }
+ }
+ ]
+ },
+ "activeManifest": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:e53c99d0-84bf-6456-386b-4672fd90758a",
+ "hash": "b64'9VAK36wGeDJ7eu9bVSM86JhlyOicuxvosaKDPZgVC/I=",
+ "pad": "b64'"
+ },
+ "claimSignature": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:e53c99d0-84bf-6456-386b-4672fd90758a/c2pa.signature",
+ "hash": "b64'LW2+3wPZtLvhIkyoklXN7z7Rzf0RJYPbKeR561ixHL4=",
+ "pad": "b64'"
+ },
+ "description": "Input ingredient 0"
+ }
+ },
+ "claim.v2": {
+ "instanceID": "969eb724-18c5-35af-10fe-4c454996914c",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:c4718f88-1bde-39ed-8835-4e22b2eff306/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA Core Generator Library",
+ "version": "822815833:822815833"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'uj+TmpMCaMsNTa+BJitjrdcLyuE/RFLbeLzZj9of4Xs="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'0Bn/iyN1eY9vVC6c7lxQMk5SqpzSgVNwi9RKOHLL/pU="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3",
+ "hash": "b64'L7UWgRdwAjuuN17e1xJV45Tixov/I1Jr/qjI6qN8/zM="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "19e8483a6248fb45b185a31f672b844007ed7d",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Media Services 1P ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "OU": "Google System 98649",
+ "CN": "Google Media Processing Services"
+ },
+ "validity": {
+ "notBefore": "2025-08-15T17:37:52+00:00",
+ "notAfter": "2026-08-10T17:37:51+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2025-10-27T18:10:41+00:00",
+ "certificateInfo": {
+ "serialNumber": "a3e6ce9b0e2d6c0443cb71902c6d8f891dd17c",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Core Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google Core Time Stamping Authority T8"
+ },
+ "validity": {
+ "notBefore": "2025-09-08T13:48:53+00:00",
+ "notAfter": "2031-09-09T01:48:52+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:c4718f88-1bde-39ed-8835-4e22b2eff306/c2pa.signature",
+ "explanation": "timestamp message digest matched: Google Core Time Stamping Authority T8"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:c4718f88-1bde-39ed-8835-4e22b2eff306/c2pa.signature",
+ "explanation": "timestamp cert trusted: Google Core Time Stamping Authority T8"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:c4718f88-1bde-39ed-8835-4e22b2eff306/c2pa.signature",
+ "explanation": "signing certificate trusted, found in System trust anchors"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:c4718f88-1bde-39ed-8835-4e22b2eff306/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:c4718f88-1bde-39ed-8835-4e22b2eff306/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:c4718f88-1bde-39ed-8835-4e22b2eff306/c2pa.assertions/c2pa.ingredient.v3",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:c4718f88-1bde-39ed-8835-4e22b2eff306/c2pa.assertions/c2pa.actions.v2",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:c4718f88-1bde-39ed-8835-4e22b2eff306/c2pa.assertions/c2pa.hash.bmff.v3",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3"
+ },
+ {
+ "code": "assertion.bmffHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:c4718f88-1bde-39ed-8835-4e22b2eff306/c2pa.assertions/c2pa.hash.bmff.v3",
+ "explanation": "BMFF hash valid"
+ }
+ ],
+ "informational": [
+ {
+ "code": "signingCredential.ocsp.notRevoked",
+ "url": "self#jumbf=/c2pa/urn:c2pa:c4718f88-1bde-39ed-8835-4e22b2eff306/c2pa.signature",
+ "explanation": "signing cert not revoked: 577753258235401352320643432677519988913335677"
+ }
+ ],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-03-31T06:57:28.395+00:00"
+ }
+ },
+ {
+ "label": "urn:c2pa:e53c99d0-84bf-6456-386b-4672fd90758a",
+ "assertions": {
+ "c2pa.hash.data": {
+ "exclusions": [
+ {
+ "start": 760,
+ "length": 21740
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'nb9XcxAmI3NofSwWURx5u+vqwppddhG2wp/HY01yPqg=",
+ "pad": "b64'AAAAAAAAAAAAAAAA"
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.created",
+ "description": "Created by Google Generative AI.",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia",
+ "parameters": {
+ "ingredients": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'EtI585pwwqoFA5u2qm5PdcUNUz3WHQoiix5IsbIkAOA=",
+ "pad": "b64'"
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1",
+ "hash": "b64'mulKvGeLbM8paw19fJ3RxpgNvOKtzp71QvueFDU94oM=",
+ "pad": "b64'"
+ }
+ ]
+ }
+ }
+ ]
+ },
+ "c2pa.ingredient.v3__1": {
+ "relationship": "inputTo",
+ "validationResults": {
+ "activeManifest": {
+ "success": [
+ {
+ "code": "signingCredential.ocsp.notRevoked",
+ "url": "self#jumbf=/c2pa/urn:c2pa:ddf3b76e-f307-5f71-9654-4bb0f19870f4/c2pa.signature"
+ },
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:ddf3b76e-f307-5f71-9654-4bb0f19870f4/c2pa.signature"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:ddf3b76e-f307-5f71-9654-4bb0f19870f4/c2pa.signature"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:ddf3b76e-f307-5f71-9654-4bb0f19870f4/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:ddf3b76e-f307-5f71-9654-4bb0f19870f4/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:ddf3b76e-f307-5f71-9654-4bb0f19870f4/c2pa.signature"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:ddf3b76e-f307-5f71-9654-4bb0f19870f4/c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:ddf3b76e-f307-5f71-9654-4bb0f19870f4/c2pa.assertions/c2pa.hash.data"
+ },
+ {
+ "code": "assertion.dataHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:ddf3b76e-f307-5f71-9654-4bb0f19870f4/c2pa.assertions/c2pa.hash.data"
+ }
+ ],
+ "informational": [],
+ "failure": []
+ }
+ },
+ "activeManifest": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:ddf3b76e-f307-5f71-9654-4bb0f19870f4",
+ "hash": "b64'ahx3mZGGK3oECYFF5MalzuoDm+iducP2oOOsw69URw0=",
+ "pad": "b64'"
+ },
+ "claimSignature": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:ddf3b76e-f307-5f71-9654-4bb0f19870f4/c2pa.signature",
+ "hash": "b64'YN/ARGwOeZA+LE+08Rjq1L3/1d3bAe+iP4R8hInkK3k=",
+ "pad": "b64'"
+ },
+ "description": "Input ingredient 1"
+ },
+ "c2pa.ingredient.v3": {
+ "relationship": "inputTo",
+ "validationResults": {
+ "activeManifest": {
+ "success": [
+ {
+ "code": "signingCredential.ocsp.notRevoked",
+ "url": "self#jumbf=/c2pa/urn:c2pa:7c3a900e-47b9-c8b2-f19c-478288cca8fb/c2pa.signature"
+ },
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:7c3a900e-47b9-c8b2-f19c-478288cca8fb/c2pa.signature"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:7c3a900e-47b9-c8b2-f19c-478288cca8fb/c2pa.signature"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:7c3a900e-47b9-c8b2-f19c-478288cca8fb/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:7c3a900e-47b9-c8b2-f19c-478288cca8fb/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:7c3a900e-47b9-c8b2-f19c-478288cca8fb/c2pa.signature"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:7c3a900e-47b9-c8b2-f19c-478288cca8fb/c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:7c3a900e-47b9-c8b2-f19c-478288cca8fb/c2pa.assertions/c2pa.hash.data"
+ },
+ {
+ "code": "assertion.dataHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:7c3a900e-47b9-c8b2-f19c-478288cca8fb/c2pa.assertions/c2pa.hash.data"
+ }
+ ],
+ "informational": [],
+ "failure": []
+ }
+ },
+ "activeManifest": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:7c3a900e-47b9-c8b2-f19c-478288cca8fb",
+ "hash": "b64'FxNkI9Qros6EHELrFEhjxNz1q8jLsVEW8cMr1xalFvk=",
+ "pad": "b64'"
+ },
+ "claimSignature": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:7c3a900e-47b9-c8b2-f19c-478288cca8fb/c2pa.signature",
+ "hash": "b64'sHT3bdzHNKjHeqHiyooA9vzURrhHtWrfVZsK154PDGE=",
+ "pad": "b64'"
+ },
+ "description": "Input ingredient 0"
+ }
+ },
+ "claim.v2": {
+ "instanceID": "10d68faa-5694-07fc-c07f-45298c46f3cc",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:e53c99d0-84bf-6456-386b-4672fd90758a/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA Core Generator Library",
+ "version": "822815833:822815833"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'EtI585pwwqoFA5u2qm5PdcUNUz3WHQoiix5IsbIkAOA="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1",
+ "hash": "b64'mulKvGeLbM8paw19fJ3RxpgNvOKtzp71QvueFDU94oM="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'9gYeFgpNtbRaeYp92Sk3By3G9hB9jQX/33kbKxayhZY="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.data",
+ "hash": "b64'mZxadqlSheTnT0fjRm+7uE/aVeraO/bAm2SHKL+suQ8="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "19e8483a6248fb45b185a31f672b844007ed7d",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Media Services 1P ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "OU": "Google System 98649",
+ "CN": "Google Media Processing Services"
+ },
+ "validity": {
+ "notBefore": "2025-08-15T17:37:52+00:00",
+ "notAfter": "2026-08-10T17:37:51+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2025-10-27T18:06:05+00:00",
+ "certificateInfo": {
+ "serialNumber": "a3e6ce9b0e2d6c0443cb71902c6d8f891dd17c",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Core Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google Core Time Stamping Authority T8"
+ },
+ "validity": {
+ "notBefore": "2025-09-08T13:48:53+00:00",
+ "notAfter": "2031-09-09T01:48:52+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [],
+ "informational": [],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-03-31T06:57:28.398+00:00"
+ }
+ },
+ {
+ "label": "urn:c2pa:ddf3b76e-f307-5f71-9654-4bb0f19870f4",
+ "assertions": {
+ "c2pa.hash.data": {
+ "exclusions": [
+ {
+ "start": 760,
+ "length": 6128
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'3B6ij90z9RvPZUmgDWsQttUBzEDfj+o2YPd5zwjoe+0=",
+ "pad": "b64'AAAAAAAAAAAAAAAA"
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.created",
+ "description": "Created by Google Generative AI.",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"
+ }
+ ]
+ }
+ },
+ "claim.v2": {
+ "instanceID": "2db38d15-bf81-686b-7f6c-4f6b7d0db517",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:ddf3b76e-f307-5f71-9654-4bb0f19870f4/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA Core Generator Library",
+ "version": "822815833:822815833"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'0iRpWnXaCPtJwlIc8RruokSxpMzbiXQ7NBM1qAtHaWY="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.data",
+ "hash": "b64'+uRKtc3oxLa3+ai48OAAs7Z3qsqvNI8f2yxucSae4ZY="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "19e8483a6248fb45b185a31f672b844007ed7d",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Media Services 1P ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "OU": "Google System 98649",
+ "CN": "Google Media Processing Services"
+ },
+ "validity": {
+ "notBefore": "2025-08-15T17:37:52+00:00",
+ "notAfter": "2026-08-10T17:37:51+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2025-10-27T18:04:38+00:00",
+ "certificateInfo": {
+ "serialNumber": "b2d5d51f21675e13bc4130aa88bc3be4e0ba1e",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Core Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google Core Time Stamping Authority T9"
+ },
+ "validity": {
+ "notBefore": "2025-09-08T13:48:55+00:00",
+ "notAfter": "2031-09-09T01:48:54+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [],
+ "informational": [],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-03-31T06:57:28.397+00:00"
+ }
+ },
+ {
+ "label": "urn:c2pa:7c3a900e-47b9-c8b2-f19c-478288cca8fb",
+ "assertions": {
+ "c2pa.hash.data": {
+ "exclusions": [
+ {
+ "start": 760,
+ "length": 6128
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'okruBQ3Osl7j9lTFRY5MKTubKfg3GdTII/rWRLnlTT0=",
+ "pad": "b64'AAAAAAAAAAAAAAAA"
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.created",
+ "description": "Created by Google Generative AI.",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"
+ }
+ ]
+ }
+ },
+ "claim.v2": {
+ "instanceID": "e705950c-d6c4-be7c-e0a3-4f609e6a1257",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:7c3a900e-47b9-c8b2-f19c-478288cca8fb/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA Core Generator Library",
+ "version": "822815833:822815833"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'0iRpWnXaCPtJwlIc8RruokSxpMzbiXQ7NBM1qAtHaWY="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.data",
+ "hash": "b64'AQCxRtrvDwr30htVmDk6eZDo6feD9JA4L6zELjk++CE="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "19e8483a6248fb45b185a31f672b844007ed7d",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Media Services 1P ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "OU": "Google System 98649",
+ "CN": "Google Media Processing Services"
+ },
+ "validity": {
+ "notBefore": "2025-08-15T17:37:52+00:00",
+ "notAfter": "2026-08-10T17:37:51+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2025-10-27T18:04:28+00:00",
+ "certificateInfo": {
+ "serialNumber": "b2d5d51f21675e13bc4130aa88bc3be4e0ba1e",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Core Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google Core Time Stamping Authority T9"
+ },
+ "validity": {
+ "notBefore": "2025-09-08T13:48:55+00:00",
+ "notAfter": "2031-09-09T01:48:54+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [],
+ "informational": [],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-03-31T06:57:28.396+00:00"
+ }
+ }
+ ],
+ "jsonGenerator": {
+ "name": "c2pa-rs",
+ "version": "0.78.0"
+ },
+ "usedITL": false,
+ "usedTestCerts": false,
+ "_conformanceToolVersion": {
+ "commit": "d291fb884e02665d169a38928392c2710f6f0953",
+ "shortCommit": "d291fb8",
+ "date": "2026-03-19 20:11:48 -0400",
+ "branch": "HEAD",
+ "generatedAt": "2026-03-23T18:42:00.448Z"
+ }
+}
\ No newline at end of file
diff --git a/src/lib/rubrics/__fixtures__/i2v.signals.json b/src/lib/rubrics/__fixtures__/i2v.signals.json
new file mode 100644
index 0000000..0a84730
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/i2v.signals.json
@@ -0,0 +1,92 @@
+{
+ "rubricName": "C2PA Asset Signals Rubric (Local)",
+ "rubricVersion": "1.0.0",
+ "manifests": [
+ {
+ "assertedBy": {
+ "CN": "Google Media Processing Services",
+ "O": "Google LLC",
+ "OU": "Google System 98649"
+ },
+ "mimeType": null,
+ "localInceptions": [
+ {
+ "trait": "inception:signal_fullyGenAIMedia",
+ "reportText": "Contains Fully GenAI Media",
+ "multiple": false
+ }
+ ],
+ "localTransformations": [],
+ "allActionsIncluded": false,
+ "ingredients": [
+ {
+ "index": 1,
+ "relationship": "inputTo"
+ }
+ ]
+ },
+ {
+ "assertedBy": {
+ "CN": "Google Media Processing Services",
+ "O": "Google LLC",
+ "OU": "Google System 98649"
+ },
+ "mimeType": null,
+ "localInceptions": [
+ {
+ "trait": "inception:signal_fullyGenAIMedia",
+ "reportText": "Contains Fully GenAI Media",
+ "multiple": false
+ }
+ ],
+ "localTransformations": [],
+ "allActionsIncluded": false,
+ "ingredients": [
+ {
+ "index": 2,
+ "relationship": "inputTo"
+ },
+ {
+ "index": 3,
+ "relationship": "inputTo"
+ }
+ ]
+ },
+ {
+ "assertedBy": {
+ "CN": "Google Media Processing Services",
+ "O": "Google LLC",
+ "OU": "Google System 98649"
+ },
+ "mimeType": null,
+ "localInceptions": [
+ {
+ "trait": "inception:signal_fullyGenAIMedia",
+ "reportText": "Contains Fully GenAI Media",
+ "multiple": false
+ }
+ ],
+ "localTransformations": [],
+ "allActionsIncluded": false,
+ "ingredients": []
+ },
+ {
+ "assertedBy": {
+ "CN": "Google Media Processing Services",
+ "O": "Google LLC",
+ "OU": "Google System 98649"
+ },
+ "mimeType": null,
+ "localInceptions": [
+ {
+ "trait": "inception:signal_fullyGenAIMedia",
+ "reportText": "Contains Fully GenAI Media",
+ "multiple": false
+ }
+ ],
+ "localTransformations": [],
+ "allActionsIncluded": false,
+ "ingredients": []
+ }
+ ]
+}
diff --git a/src/lib/rubrics/__fixtures__/ii2i.conformance.json b/src/lib/rubrics/__fixtures__/ii2i.conformance.json
new file mode 100644
index 0000000..c17a9eb
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/ii2i.conformance.json
@@ -0,0 +1,71 @@
+{
+ "rubricName": "C2PA Asset Conformance 0.1 Spec 2.2 Rubric",
+ "rubricVersion": "0.1.0",
+ "true": {
+ "validation": [
+ {
+ "trait": "active_manifest_urn",
+ "reportText": "Active manifest URN uses standard 'urn:c2pa:' prefix"
+ },
+ {
+ "trait": "inception_action_position",
+ "reportText": "Inception action is correctly positioned as the first item in the first created actions assertion"
+ },
+ {
+ "trait": "ingredient_relationship_values",
+ "reportText": "All ingredient assertions have valid relationship values"
+ },
+ {
+ "trait": "ingredient_v3_no_active_manifest",
+ "reportText": "Ingredient v3 assertions without activeManifest correctly omit validationResults"
+ },
+ {
+ "trait": "no_deprecated_actions",
+ "reportText": "No deprecated actions used"
+ },
+ {
+ "trait": "no_deprecated_assertions",
+ "reportText": "No deprecated standard assertions found"
+ },
+ {
+ "trait": "no_unsupported_assertions",
+ "reportText": "All standard assertions are supported for Spec 2.2"
+ },
+ {
+ "trait": "review_ratings_datasource",
+ "reportText": "No invalid co-occurrence of reviewRatings and human entry dataSource"
+ },
+ {
+ "trait": "trusted_data_present",
+ "reportText": "Validation results are present for trust analysis"
+ },
+ {
+ "trait": "trusted_success",
+ "reportText": "Asset is trusted"
+ },
+ {
+ "trait": "update_manifest_constraints",
+ "reportText": "Update manifest constraints are satisfied (or manifest is not an update manifest)"
+ },
+ {
+ "trait": "valid_data_present",
+ "reportText": "Validation results are present for integrity analysis"
+ },
+ {
+ "trait": "valid_success",
+ "reportText": "No validation mismatches found"
+ },
+ {
+ "trait": "well_formed_data_present",
+ "reportText": "Validation results are present for structural analysis"
+ },
+ {
+ "trait": "well_formed_success",
+ "reportText": "No structural failures found"
+ }
+ ]
+ },
+ "false": {
+ "validation": []
+ }
+}
diff --git a/src/lib/rubrics/__fixtures__/ii2i.json b/src/lib/rubrics/__fixtures__/ii2i.json
new file mode 100644
index 0000000..e3b2056
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/ii2i.json
@@ -0,0 +1,495 @@
+{
+ "@context": {
+ "@vocab": "https://contentcredentials.org/crjson",
+ "extras": "https://contentcredentials.org/crjson/extras"
+ },
+ "manifests": [
+ {
+ "label": "urn:c2pa:548990cb-129c-8f6b-459b-4031bb7f6fc0",
+ "assertions": {
+ "c2pa.hash.data": {
+ "exclusions": [
+ {
+ "start": 760,
+ "length": 21740
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'eIhikxuoKRLfCK7Q/7NxQVjw0HYjFMs9Xrue4XR7two=",
+ "pad": "b64'AAAAAAAAAAAAAAAA"
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.created",
+ "description": "Created by Google Generative AI.",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia",
+ "parameters": {
+ "ingredients": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'EtI585pwwqoFA5u2qm5PdcUNUz3WHQoiix5IsbIkAOA=",
+ "pad": "b64'"
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1",
+ "hash": "b64'mulKvGeLbM8paw19fJ3RxpgNvOKtzp71QvueFDU94oM=",
+ "pad": "b64'"
+ }
+ ]
+ }
+ }
+ ]
+ },
+ "c2pa.ingredient.v3__1": {
+ "relationship": "inputTo",
+ "validationResults": {
+ "activeManifest": {
+ "success": [
+ {
+ "code": "signingCredential.ocsp.notRevoked",
+ "url": "self#jumbf=/c2pa/urn:c2pa:ddf3b76e-f307-5f71-9654-4bb0f19870f4/c2pa.signature"
+ },
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:ddf3b76e-f307-5f71-9654-4bb0f19870f4/c2pa.signature"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:ddf3b76e-f307-5f71-9654-4bb0f19870f4/c2pa.signature"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:ddf3b76e-f307-5f71-9654-4bb0f19870f4/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:ddf3b76e-f307-5f71-9654-4bb0f19870f4/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:ddf3b76e-f307-5f71-9654-4bb0f19870f4/c2pa.signature"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:ddf3b76e-f307-5f71-9654-4bb0f19870f4/c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:ddf3b76e-f307-5f71-9654-4bb0f19870f4/c2pa.assertions/c2pa.hash.data"
+ },
+ {
+ "code": "assertion.dataHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:ddf3b76e-f307-5f71-9654-4bb0f19870f4/c2pa.assertions/c2pa.hash.data"
+ }
+ ],
+ "informational": [],
+ "failure": []
+ }
+ },
+ "activeManifest": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:ddf3b76e-f307-5f71-9654-4bb0f19870f4",
+ "hash": "b64'ahx3mZGGK3oECYFF5MalzuoDm+iducP2oOOsw69URw0=",
+ "pad": "b64'"
+ },
+ "claimSignature": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:ddf3b76e-f307-5f71-9654-4bb0f19870f4/c2pa.signature",
+ "hash": "b64'YN/ARGwOeZA+LE+08Rjq1L3/1d3bAe+iP4R8hInkK3k=",
+ "pad": "b64'"
+ },
+ "description": "Input ingredient 1"
+ },
+ "c2pa.ingredient.v3": {
+ "relationship": "inputTo",
+ "validationResults": {
+ "activeManifest": {
+ "success": [
+ {
+ "code": "signingCredential.ocsp.notRevoked",
+ "url": "self#jumbf=/c2pa/urn:c2pa:7c3a900e-47b9-c8b2-f19c-478288cca8fb/c2pa.signature"
+ },
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:7c3a900e-47b9-c8b2-f19c-478288cca8fb/c2pa.signature"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:7c3a900e-47b9-c8b2-f19c-478288cca8fb/c2pa.signature"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:7c3a900e-47b9-c8b2-f19c-478288cca8fb/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:7c3a900e-47b9-c8b2-f19c-478288cca8fb/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:7c3a900e-47b9-c8b2-f19c-478288cca8fb/c2pa.signature"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:7c3a900e-47b9-c8b2-f19c-478288cca8fb/c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:7c3a900e-47b9-c8b2-f19c-478288cca8fb/c2pa.assertions/c2pa.hash.data"
+ },
+ {
+ "code": "assertion.dataHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:7c3a900e-47b9-c8b2-f19c-478288cca8fb/c2pa.assertions/c2pa.hash.data"
+ }
+ ],
+ "informational": [],
+ "failure": []
+ }
+ },
+ "activeManifest": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:7c3a900e-47b9-c8b2-f19c-478288cca8fb",
+ "hash": "b64'FxNkI9Qros6EHELrFEhjxNz1q8jLsVEW8cMr1xalFvk=",
+ "pad": "b64'"
+ },
+ "claimSignature": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:7c3a900e-47b9-c8b2-f19c-478288cca8fb/c2pa.signature",
+ "hash": "b64'sHT3bdzHNKjHeqHiyooA9vzURrhHtWrfVZsK154PDGE=",
+ "pad": "b64'"
+ },
+ "description": "Input ingredient 0"
+ }
+ },
+ "claim.v2": {
+ "instanceID": "46728428-8a10-649b-ab15-420ca0f83948",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:548990cb-129c-8f6b-459b-4031bb7f6fc0/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA Core Generator Library",
+ "version": "822815833:822815833"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'EtI585pwwqoFA5u2qm5PdcUNUz3WHQoiix5IsbIkAOA="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1",
+ "hash": "b64'mulKvGeLbM8paw19fJ3RxpgNvOKtzp71QvueFDU94oM="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'9gYeFgpNtbRaeYp92Sk3By3G9hB9jQX/33kbKxayhZY="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.data",
+ "hash": "b64'u6jT9aJ9Fux42+0AOU+VjkkOoNgH3FqK95Zb1jbL4us="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "19e8483a6248fb45b185a31f672b844007ed7d",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Media Services 1P ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "OU": "Google System 98649",
+ "CN": "Google Media Processing Services"
+ },
+ "validity": {
+ "notBefore": "2025-08-15T17:37:52+00:00",
+ "notAfter": "2026-08-10T17:37:51+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2025-10-27T18:06:05+00:00",
+ "certificateInfo": {
+ "serialNumber": "a3e6ce9b0e2d6c0443cb71902c6d8f891dd17c",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Core Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google Core Time Stamping Authority T8"
+ },
+ "validity": {
+ "notBefore": "2025-09-08T13:48:53+00:00",
+ "notAfter": "2031-09-09T01:48:52+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:548990cb-129c-8f6b-459b-4031bb7f6fc0/c2pa.signature",
+ "explanation": "timestamp message digest matched: Google Core Time Stamping Authority T8"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:548990cb-129c-8f6b-459b-4031bb7f6fc0/c2pa.signature",
+ "explanation": "timestamp cert trusted: Google Core Time Stamping Authority T8"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:548990cb-129c-8f6b-459b-4031bb7f6fc0/c2pa.signature",
+ "explanation": "signing certificate trusted, found in System trust anchors"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:548990cb-129c-8f6b-459b-4031bb7f6fc0/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:548990cb-129c-8f6b-459b-4031bb7f6fc0/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:548990cb-129c-8f6b-459b-4031bb7f6fc0/c2pa.assertions/c2pa.ingredient.v3",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:548990cb-129c-8f6b-459b-4031bb7f6fc0/c2pa.assertions/c2pa.ingredient.v3__1",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.ingredient.v3__1"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:548990cb-129c-8f6b-459b-4031bb7f6fc0/c2pa.assertions/c2pa.actions.v2",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:548990cb-129c-8f6b-459b-4031bb7f6fc0/c2pa.assertions/c2pa.hash.data",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.hash.data"
+ },
+ {
+ "code": "assertion.dataHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:548990cb-129c-8f6b-459b-4031bb7f6fc0/c2pa.assertions/c2pa.hash.data",
+ "explanation": "data hash valid"
+ }
+ ],
+ "informational": [
+ {
+ "code": "signingCredential.ocsp.notRevoked",
+ "url": "self#jumbf=/c2pa/urn:c2pa:548990cb-129c-8f6b-459b-4031bb7f6fc0/c2pa.signature",
+ "explanation": "signing cert not revoked: 577753258235401352320643432677519988913335677"
+ }
+ ],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-03-31T03:50:45.071+00:00"
+ }
+ },
+ {
+ "label": "urn:c2pa:ddf3b76e-f307-5f71-9654-4bb0f19870f4",
+ "assertions": {
+ "c2pa.hash.data": {
+ "exclusions": [
+ {
+ "start": 760,
+ "length": 6128
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'3B6ij90z9RvPZUmgDWsQttUBzEDfj+o2YPd5zwjoe+0=",
+ "pad": "b64'AAAAAAAAAAAAAAAA"
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.created",
+ "description": "Created by Google Generative AI.",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"
+ }
+ ]
+ }
+ },
+ "claim.v2": {
+ "instanceID": "2db38d15-bf81-686b-7f6c-4f6b7d0db517",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:ddf3b76e-f307-5f71-9654-4bb0f19870f4/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA Core Generator Library",
+ "version": "822815833:822815833"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'0iRpWnXaCPtJwlIc8RruokSxpMzbiXQ7NBM1qAtHaWY="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.data",
+ "hash": "b64'+uRKtc3oxLa3+ai48OAAs7Z3qsqvNI8f2yxucSae4ZY="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "19e8483a6248fb45b185a31f672b844007ed7d",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Media Services 1P ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "OU": "Google System 98649",
+ "CN": "Google Media Processing Services"
+ },
+ "validity": {
+ "notBefore": "2025-08-15T17:37:52+00:00",
+ "notAfter": "2026-08-10T17:37:51+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2025-10-27T18:04:38+00:00",
+ "certificateInfo": {
+ "serialNumber": "b2d5d51f21675e13bc4130aa88bc3be4e0ba1e",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Core Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google Core Time Stamping Authority T9"
+ },
+ "validity": {
+ "notBefore": "2025-09-08T13:48:55+00:00",
+ "notAfter": "2031-09-09T01:48:54+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [],
+ "informational": [],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-03-31T03:50:45.073+00:00"
+ }
+ },
+ {
+ "label": "urn:c2pa:7c3a900e-47b9-c8b2-f19c-478288cca8fb",
+ "assertions": {
+ "c2pa.hash.data": {
+ "exclusions": [
+ {
+ "start": 760,
+ "length": 6128
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'okruBQ3Osl7j9lTFRY5MKTubKfg3GdTII/rWRLnlTT0=",
+ "pad": "b64'AAAAAAAAAAAAAAAA"
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.created",
+ "description": "Created by Google Generative AI.",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"
+ }
+ ]
+ }
+ },
+ "claim.v2": {
+ "instanceID": "e705950c-d6c4-be7c-e0a3-4f609e6a1257",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:7c3a900e-47b9-c8b2-f19c-478288cca8fb/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA Core Generator Library",
+ "version": "822815833:822815833"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'0iRpWnXaCPtJwlIc8RruokSxpMzbiXQ7NBM1qAtHaWY="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.data",
+ "hash": "b64'AQCxRtrvDwr30htVmDk6eZDo6feD9JA4L6zELjk++CE="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "19e8483a6248fb45b185a31f672b844007ed7d",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Media Services 1P ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "OU": "Google System 98649",
+ "CN": "Google Media Processing Services"
+ },
+ "validity": {
+ "notBefore": "2025-08-15T17:37:52+00:00",
+ "notAfter": "2026-08-10T17:37:51+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2025-10-27T18:04:28+00:00",
+ "certificateInfo": {
+ "serialNumber": "b2d5d51f21675e13bc4130aa88bc3be4e0ba1e",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Core Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google Core Time Stamping Authority T9"
+ },
+ "validity": {
+ "notBefore": "2025-09-08T13:48:55+00:00",
+ "notAfter": "2031-09-09T01:48:54+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [],
+ "informational": [],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-03-31T03:50:45.072+00:00"
+ }
+ }
+ ],
+ "jsonGenerator": {
+ "name": "c2pa-rs",
+ "version": "0.78.0"
+ },
+ "usedITL": false,
+ "usedTestCerts": false,
+ "_conformanceToolVersion": {
+ "commit": "d291fb884e02665d169a38928392c2710f6f0953",
+ "shortCommit": "d291fb8",
+ "date": "2026-03-19 20:11:48 -0400",
+ "branch": "HEAD",
+ "generatedAt": "2026-03-23T18:42:00.448Z"
+ }
+}
\ No newline at end of file
diff --git a/src/lib/rubrics/__fixtures__/ii2i.signals.json b/src/lib/rubrics/__fixtures__/ii2i.signals.json
new file mode 100644
index 0000000..8c1fde4
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/ii2i.signals.json
@@ -0,0 +1,69 @@
+{
+ "rubricName": "C2PA Asset Signals Rubric (Local)",
+ "rubricVersion": "1.0.0",
+ "manifests": [
+ {
+ "assertedBy": {
+ "CN": "Google Media Processing Services",
+ "O": "Google LLC",
+ "OU": "Google System 98649"
+ },
+ "mimeType": null,
+ "localInceptions": [
+ {
+ "trait": "inception:signal_fullyGenAIMedia",
+ "reportText": "Contains Fully GenAI Media",
+ "multiple": false
+ }
+ ],
+ "localTransformations": [],
+ "allActionsIncluded": false,
+ "ingredients": [
+ {
+ "index": 1,
+ "relationship": "inputTo"
+ },
+ {
+ "index": 2,
+ "relationship": "inputTo"
+ }
+ ]
+ },
+ {
+ "assertedBy": {
+ "CN": "Google Media Processing Services",
+ "O": "Google LLC",
+ "OU": "Google System 98649"
+ },
+ "mimeType": null,
+ "localInceptions": [
+ {
+ "trait": "inception:signal_fullyGenAIMedia",
+ "reportText": "Contains Fully GenAI Media",
+ "multiple": false
+ }
+ ],
+ "localTransformations": [],
+ "allActionsIncluded": false,
+ "ingredients": []
+ },
+ {
+ "assertedBy": {
+ "CN": "Google Media Processing Services",
+ "O": "Google LLC",
+ "OU": "Google System 98649"
+ },
+ "mimeType": null,
+ "localInceptions": [
+ {
+ "trait": "inception:signal_fullyGenAIMedia",
+ "reportText": "Contains Fully GenAI Media",
+ "multiple": false
+ }
+ ],
+ "localTransformations": [],
+ "allActionsIncluded": false,
+ "ingredients": []
+ }
+ ]
+}
diff --git a/src/lib/rubrics/__fixtures__/r2i-unknownProvenance.conformance.json b/src/lib/rubrics/__fixtures__/r2i-unknownProvenance.conformance.json
new file mode 100644
index 0000000..c17a9eb
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/r2i-unknownProvenance.conformance.json
@@ -0,0 +1,71 @@
+{
+ "rubricName": "C2PA Asset Conformance 0.1 Spec 2.2 Rubric",
+ "rubricVersion": "0.1.0",
+ "true": {
+ "validation": [
+ {
+ "trait": "active_manifest_urn",
+ "reportText": "Active manifest URN uses standard 'urn:c2pa:' prefix"
+ },
+ {
+ "trait": "inception_action_position",
+ "reportText": "Inception action is correctly positioned as the first item in the first created actions assertion"
+ },
+ {
+ "trait": "ingredient_relationship_values",
+ "reportText": "All ingredient assertions have valid relationship values"
+ },
+ {
+ "trait": "ingredient_v3_no_active_manifest",
+ "reportText": "Ingredient v3 assertions without activeManifest correctly omit validationResults"
+ },
+ {
+ "trait": "no_deprecated_actions",
+ "reportText": "No deprecated actions used"
+ },
+ {
+ "trait": "no_deprecated_assertions",
+ "reportText": "No deprecated standard assertions found"
+ },
+ {
+ "trait": "no_unsupported_assertions",
+ "reportText": "All standard assertions are supported for Spec 2.2"
+ },
+ {
+ "trait": "review_ratings_datasource",
+ "reportText": "No invalid co-occurrence of reviewRatings and human entry dataSource"
+ },
+ {
+ "trait": "trusted_data_present",
+ "reportText": "Validation results are present for trust analysis"
+ },
+ {
+ "trait": "trusted_success",
+ "reportText": "Asset is trusted"
+ },
+ {
+ "trait": "update_manifest_constraints",
+ "reportText": "Update manifest constraints are satisfied (or manifest is not an update manifest)"
+ },
+ {
+ "trait": "valid_data_present",
+ "reportText": "Validation results are present for integrity analysis"
+ },
+ {
+ "trait": "valid_success",
+ "reportText": "No validation mismatches found"
+ },
+ {
+ "trait": "well_formed_data_present",
+ "reportText": "Validation results are present for structural analysis"
+ },
+ {
+ "trait": "well_formed_success",
+ "reportText": "No structural failures found"
+ }
+ ]
+ },
+ "false": {
+ "validation": []
+ }
+}
diff --git a/src/lib/rubrics/__fixtures__/r2i-unknownProvenance.json b/src/lib/rubrics/__fixtures__/r2i-unknownProvenance.json
new file mode 100644
index 0000000..fc91ce8
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/r2i-unknownProvenance.json
@@ -0,0 +1,205 @@
+{
+ "@context": {
+ "@vocab": "https://contentcredentials.org/crjson",
+ "extras": "https://contentcredentials.org/crjson/extras"
+ },
+ "manifests": [
+ {
+ "label": "urn:c2pa:5b7385c3-c9df-ab30-825d-451b1495bc58",
+ "assertions": {
+ "c2pa.hash.data": {
+ "exclusions": [
+ {
+ "start": 33,
+ "length": 6199
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'0gHjbOEF1/KtHjn9F7p25EX2SISSPHcPjGj7TRLlFUk=",
+ "pad": "b64'AAAAAAAAAAAAAAAAAA=="
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.opened",
+ "parameters": {
+ "ingredients": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'vErZhOwuGNdB5FT/hTJX5fzvnBYUbEKaWLI4OeE4ExU=",
+ "pad": "b64'"
+ }
+ ]
+ }
+ },
+ {
+ "action": "c2pa.edited",
+ "description": "Added visible watermark",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/composite"
+ },
+ {
+ "action": "c2pa.converted",
+ "description": "Converted to .png"
+ }
+ ]
+ },
+ "c2pa.ingredient.v3": {
+ "relationship": "parentOf"
+ }
+ },
+ "claim.v2": {
+ "instanceID": "b4b9809b-ddce-d6ae-d9fc-4c2f92d398c8",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:5b7385c3-c9df-ab30-825d-451b1495bc58/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA Core Generator Library",
+ "version": "891313448:891313448"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'vErZhOwuGNdB5FT/hTJX5fzvnBYUbEKaWLI4OeE4ExU="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'D8LOn5a2qXw5CU2TGZ3xlmqJM9RASLmEDsGCCFiB8O8="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.data",
+ "hash": "b64'HTJmCCJ/1nRCQfkysFr2CjIv+93a9Lj/IAqOcwZkS0w="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "8fe1cc59a730ea52f84565289c7bc5aad9a6e1",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Media Services 1P ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "OU": "Google System 67154",
+ "CN": "Google Media Processing Services"
+ },
+ "validity": {
+ "notBefore": "2026-01-29T19:10:05+00:00",
+ "notAfter": "2027-01-24T19:10:04+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2026-04-01T06:44:25+00:00",
+ "certificateInfo": {
+ "serialNumber": "b2d5d51f21675e13bc4130aa88bc3be4e0ba1e",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Core Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google Core Time Stamping Authority T9"
+ },
+ "validity": {
+ "notBefore": "2025-09-08T13:48:55+00:00",
+ "notAfter": "2031-09-09T01:48:54+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:5b7385c3-c9df-ab30-825d-451b1495bc58/c2pa.signature",
+ "explanation": "timestamp message digest matched: Google Core Time Stamping Authority T9"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:5b7385c3-c9df-ab30-825d-451b1495bc58/c2pa.signature",
+ "explanation": "timestamp cert trusted: Google Core Time Stamping Authority T9"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:5b7385c3-c9df-ab30-825d-451b1495bc58/c2pa.signature",
+ "explanation": "signing certificate trusted, found in System trust anchors"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:5b7385c3-c9df-ab30-825d-451b1495bc58/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:5b7385c3-c9df-ab30-825d-451b1495bc58/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:5b7385c3-c9df-ab30-825d-451b1495bc58/c2pa.assertions/c2pa.ingredient.v3",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:5b7385c3-c9df-ab30-825d-451b1495bc58/c2pa.assertions/c2pa.actions.v2",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:5b7385c3-c9df-ab30-825d-451b1495bc58/c2pa.assertions/c2pa.hash.data",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.hash.data"
+ },
+ {
+ "code": "assertion.dataHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:5b7385c3-c9df-ab30-825d-451b1495bc58/c2pa.assertions/c2pa.hash.data",
+ "explanation": "data hash valid"
+ }
+ ],
+ "informational": [
+ {
+ "code": "signingCredential.ocsp.notRevoked",
+ "url": "self#jumbf=/c2pa/urn:c2pa:5b7385c3-c9df-ab30-825d-451b1495bc58/c2pa.signature",
+ "explanation": "signing cert not revoked: 3208676364496774357143389257185012302059513569"
+ }
+ ],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-04-01T06:45:40.578+00:00"
+ },
+ "ingredientDeltas": [
+ {
+ "ingredientAssertionURI": "self#jumbf=/c2pa/urn:c2pa:5b7385c3-c9df-ab30-825d-451b1495bc58/c2pa.assertions/c2pa.ingredient.v3",
+ "validationDeltas": {
+ "success": [],
+ "informational": [
+ {
+ "code": "ingredient.unknownProvenance",
+ "url": "self#jumbf=/c2pa/urn:c2pa:5b7385c3-c9df-ab30-825d-451b1495bc58/c2pa.assertions/c2pa.ingredient.v3",
+ "explanation": "no title: ingredient does not have provenance"
+ }
+ ],
+ "failure": []
+ }
+ }
+ ]
+ }
+ ],
+ "jsonGenerator": {
+ "name": "c2pa-rs",
+ "version": "0.78.0"
+ },
+ "usedITL": false,
+ "usedTestCerts": false,
+ "_conformanceToolVersion": {
+ "commit": "d209e393a529f8f54e06842aea3216a558b1caa3",
+ "shortCommit": "d209e39",
+ "date": "2026-03-31 12:02:12 -0400",
+ "branch": "HEAD",
+ "generatedAt": "2026-03-31T16:07:04.350Z"
+ }
+}
\ No newline at end of file
diff --git a/src/lib/rubrics/__fixtures__/r2i-unknownProvenance.signals.json b/src/lib/rubrics/__fixtures__/r2i-unknownProvenance.signals.json
new file mode 100644
index 0000000..7c5437e
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/r2i-unknownProvenance.signals.json
@@ -0,0 +1,40 @@
+{
+ "rubricName": "C2PA Asset Signals Rubric (Local)",
+ "rubricVersion": "1.0.0",
+ "manifests": [
+ {
+ "assertedBy": {
+ "CN": "Google Media Processing Services",
+ "O": "Google LLC",
+ "OU": "Google System 67154"
+ },
+ "mimeType": null,
+ "localInceptions": [
+ {
+ "trait": "inception:signal_mediaUnknownProvenance",
+ "reportText": "Contains Media of Unknown Provenance",
+ "multiple": false
+ }
+ ],
+ "localTransformations": [
+ {
+ "trait": "transformation:signal_editorialNonAI",
+ "reportText": "Contains Editorial Non-GenAI Transformations",
+ "multiple": false
+ },
+ {
+ "trait": "transformation:signal_editorialPossiblyGenAI",
+ "reportText": "Contains Editorial Transformations Possibly Using GenAI",
+ "multiple": false
+ },
+ {
+ "trait": "transformation:signal_nonEditorial",
+ "reportText": "Contains Non-editorial Transformations",
+ "multiple": false
+ }
+ ],
+ "allActionsIncluded": false,
+ "ingredients": []
+ }
+ ]
+}
diff --git a/src/lib/rubrics/__fixtures__/t2i-gemini.conformance.json b/src/lib/rubrics/__fixtures__/t2i-gemini.conformance.json
new file mode 100644
index 0000000..c17a9eb
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/t2i-gemini.conformance.json
@@ -0,0 +1,71 @@
+{
+ "rubricName": "C2PA Asset Conformance 0.1 Spec 2.2 Rubric",
+ "rubricVersion": "0.1.0",
+ "true": {
+ "validation": [
+ {
+ "trait": "active_manifest_urn",
+ "reportText": "Active manifest URN uses standard 'urn:c2pa:' prefix"
+ },
+ {
+ "trait": "inception_action_position",
+ "reportText": "Inception action is correctly positioned as the first item in the first created actions assertion"
+ },
+ {
+ "trait": "ingredient_relationship_values",
+ "reportText": "All ingredient assertions have valid relationship values"
+ },
+ {
+ "trait": "ingredient_v3_no_active_manifest",
+ "reportText": "Ingredient v3 assertions without activeManifest correctly omit validationResults"
+ },
+ {
+ "trait": "no_deprecated_actions",
+ "reportText": "No deprecated actions used"
+ },
+ {
+ "trait": "no_deprecated_assertions",
+ "reportText": "No deprecated standard assertions found"
+ },
+ {
+ "trait": "no_unsupported_assertions",
+ "reportText": "All standard assertions are supported for Spec 2.2"
+ },
+ {
+ "trait": "review_ratings_datasource",
+ "reportText": "No invalid co-occurrence of reviewRatings and human entry dataSource"
+ },
+ {
+ "trait": "trusted_data_present",
+ "reportText": "Validation results are present for trust analysis"
+ },
+ {
+ "trait": "trusted_success",
+ "reportText": "Asset is trusted"
+ },
+ {
+ "trait": "update_manifest_constraints",
+ "reportText": "Update manifest constraints are satisfied (or manifest is not an update manifest)"
+ },
+ {
+ "trait": "valid_data_present",
+ "reportText": "Validation results are present for integrity analysis"
+ },
+ {
+ "trait": "valid_success",
+ "reportText": "No validation mismatches found"
+ },
+ {
+ "trait": "well_formed_data_present",
+ "reportText": "Validation results are present for structural analysis"
+ },
+ {
+ "trait": "well_formed_success",
+ "reportText": "No structural failures found"
+ }
+ ]
+ },
+ "false": {
+ "validation": []
+ }
+}
diff --git a/src/lib/rubrics/__fixtures__/t2i-gemini.json b/src/lib/rubrics/__fixtures__/t2i-gemini.json
new file mode 100644
index 0000000..c283fb7
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/t2i-gemini.json
@@ -0,0 +1,568 @@
+{
+ "@context": {
+ "@vocab": "https://contentcredentials.org/crjson",
+ "extras": "https://contentcredentials.org/crjson/extras"
+ },
+ "manifests": [
+ {
+ "label": "urn:c2pa:066b9fa1-6c2f-2979-5767-4265a7091b56",
+ "assertions": {
+ "c2pa.hash.data": {
+ "exclusions": [
+ {
+ "start": 33,
+ "length": 21965
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'+rGXWeBE35/Edb/yXQQCXFbUPVTaPPTTKVn0BoMjNU0=",
+ "pad": "b64'AAAAAAAAAAAAAAAAAA=="
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.opened",
+ "parameters": {
+ "ingredients": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'9SvNuTrbTqjrDJ8WRGeW1RxtpZJZk7jOmJYrLUx0law=",
+ "pad": "b64'"
+ }
+ ]
+ }
+ },
+ {
+ "action": "c2pa.edited",
+ "description": "Added imperceptible SynthID watermark",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"
+ },
+ {
+ "action": "c2pa.edited",
+ "description": "Added visible watermark",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/composite"
+ },
+ {
+ "action": "c2pa.converted",
+ "description": "Converted to .png"
+ }
+ ]
+ },
+ "c2pa.ingredient.v3": {
+ "relationship": "parentOf",
+ "validationResults": {
+ "activeManifest": {
+ "success": [
+ {
+ "code": "signingCredential.ocsp.notRevoked",
+ "url": "self#jumbf=/c2pa/urn:c2pa:c9d683be-dbbc-3000-ff87-4c8041d75d47/c2pa.signature"
+ },
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:c9d683be-dbbc-3000-ff87-4c8041d75d47/c2pa.signature"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:c9d683be-dbbc-3000-ff87-4c8041d75d47/c2pa.signature"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:c9d683be-dbbc-3000-ff87-4c8041d75d47/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:c9d683be-dbbc-3000-ff87-4c8041d75d47/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:c9d683be-dbbc-3000-ff87-4c8041d75d47/c2pa.signature"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:c9d683be-dbbc-3000-ff87-4c8041d75d47/c2pa.assertions/c2pa.ingredient.v3"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:c9d683be-dbbc-3000-ff87-4c8041d75d47/c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:c9d683be-dbbc-3000-ff87-4c8041d75d47/c2pa.assertions/c2pa.hash.data"
+ },
+ {
+ "code": "ingredient.claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:c9d683be-dbbc-3000-ff87-4c8041d75d47/c2pa.assertions/c2pa.ingredient.v3"
+ },
+ {
+ "code": "assertion.dataHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:c9d683be-dbbc-3000-ff87-4c8041d75d47/c2pa.assertions/c2pa.hash.data"
+ }
+ ],
+ "informational": [],
+ "failure": []
+ },
+ "ingredientDeltas": [
+ {
+ "ingredientAssertionURI": "self#jumbf=/c2pa/urn:c2pa:c9d683be-dbbc-3000-ff87-4c8041d75d47/c2pa.assertions/c2pa.ingredient.v3",
+ "validationDeltas": {
+ "success": [],
+ "informational": [],
+ "failure": []
+ }
+ }
+ ]
+ },
+ "activeManifest": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:c9d683be-dbbc-3000-ff87-4c8041d75d47",
+ "hash": "b64'c26thwok08lNHdxte9fMIwOPdlfiT/xpibf25PrBJh4=",
+ "pad": "b64'"
+ },
+ "claimSignature": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:c9d683be-dbbc-3000-ff87-4c8041d75d47/c2pa.signature",
+ "hash": "b64'JVOuj1sKjHV8/No+9Ib6sIUcwiNcVSeoQbl8TOd7dzM=",
+ "pad": "b64'"
+ }
+ }
+ },
+ "claim.v2": {
+ "instanceID": "8fcf9f18-6109-c2f5-f06f-45d88351b204",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:066b9fa1-6c2f-2979-5767-4265a7091b56/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA Core Generator Library",
+ "version": "876242061:876242061"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'9SvNuTrbTqjrDJ8WRGeW1RxtpZJZk7jOmJYrLUx0law="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'T+AIJvGeTmkzzZvfE4LalzhCDHnUGQRnjZwL0KlPtDg="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.data",
+ "hash": "b64'hGLwM3W5Erk52/sHe//NZ8fgOIkAtrVk14m1dG9i+Iw="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "8fe1cc59a730ea52f84565289c7bc5aad9a6e1",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Media Services 1P ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "OU": "Google System 67154",
+ "CN": "Google Media Processing Services"
+ },
+ "validity": {
+ "notBefore": "2026-01-29T19:10:05+00:00",
+ "notAfter": "2027-01-24T19:10:04+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2026-03-04T01:35:19+00:00",
+ "certificateInfo": {
+ "serialNumber": "f043dfdd776347ea5f7c3ad45a70b5aee2455",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Core Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google Core Time Stamping Authority T10"
+ },
+ "validity": {
+ "notBefore": "2025-09-08T13:48:57+00:00",
+ "notAfter": "2031-09-09T01:48:56+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:066b9fa1-6c2f-2979-5767-4265a7091b56/c2pa.signature",
+ "explanation": "timestamp message digest matched: Google Core Time Stamping Authority T10"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:066b9fa1-6c2f-2979-5767-4265a7091b56/c2pa.signature",
+ "explanation": "timestamp cert trusted: Google Core Time Stamping Authority T10"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:066b9fa1-6c2f-2979-5767-4265a7091b56/c2pa.signature",
+ "explanation": "signing certificate trusted, found in System trust anchors"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:066b9fa1-6c2f-2979-5767-4265a7091b56/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:066b9fa1-6c2f-2979-5767-4265a7091b56/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:066b9fa1-6c2f-2979-5767-4265a7091b56/c2pa.assertions/c2pa.ingredient.v3",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:066b9fa1-6c2f-2979-5767-4265a7091b56/c2pa.assertions/c2pa.actions.v2",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:066b9fa1-6c2f-2979-5767-4265a7091b56/c2pa.assertions/c2pa.hash.data",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.hash.data"
+ },
+ {
+ "code": "assertion.dataHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:066b9fa1-6c2f-2979-5767-4265a7091b56/c2pa.assertions/c2pa.hash.data",
+ "explanation": "data hash valid"
+ }
+ ],
+ "informational": [
+ {
+ "code": "signingCredential.ocsp.notRevoked",
+ "url": "self#jumbf=/c2pa/urn:c2pa:066b9fa1-6c2f-2979-5767-4265a7091b56/c2pa.signature",
+ "explanation": "signing cert not revoked: 3208676364496774357143389257185012302059513569"
+ }
+ ],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-03-31T06:51:17.190+00:00"
+ },
+ "ingredientDeltas": [
+ {
+ "ingredientAssertionURI": "self#jumbf=/c2pa/urn:c2pa:066b9fa1-6c2f-2979-5767-4265a7091b56/c2pa.assertions/c2pa.ingredient.v3",
+ "validationDeltas": {
+ "success": [
+ {
+ "code": "ingredient.manifest.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:c9d683be-dbbc-3000-ff87-4c8041d75d47",
+ "explanation": "ingredient hash matched"
+ }
+ ],
+ "informational": [
+ {
+ "code": "signingCredential.ocsp.notRevoked",
+ "url": "self#jumbf=/c2pa/urn:c2pa:c9d683be-dbbc-3000-ff87-4c8041d75d47/c2pa.signature",
+ "explanation": "signing cert not revoked: 3538769668273185232431161852199326737017347790"
+ }
+ ],
+ "failure": []
+ }
+ }
+ ]
+ },
+ {
+ "label": "urn:c2pa:c9d683be-dbbc-3000-ff87-4c8041d75d47",
+ "assertions": {
+ "c2pa.hash.data": {
+ "exclusions": [
+ {
+ "start": 20,
+ "length": 13763
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'yRilZMc8UgKQ61TIWArfbGE3iLzaCTPtpwtO4idWzKU=",
+ "pad": "b64'AAAAAAAAAAAAAAAAAAA="
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.created",
+ "description": "Created by Google Generative AI.",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia",
+ "parameters": {
+ "ingredients": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'WeQCfBdSGx8QUYVPUetAeTjuFJr7Nb0Iil2P3YeRvsI=",
+ "pad": "b64'"
+ }
+ ]
+ }
+ },
+ {
+ "action": "c2pa.edited",
+ "description": "Applied imperceptible SynthID watermark.",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"
+ }
+ ]
+ },
+ "c2pa.ingredient.v3": {
+ "relationship": "inputTo",
+ "dc:format": "image/jpeg",
+ "validationResults": {
+ "activeManifest": {
+ "success": [
+ {
+ "code": "signingCredential.ocsp.notRevoked",
+ "url": "self#jumbf=/c2pa/urn:c2pa:03e0b8ea-59e3-7669-09eb-4449e66c5f3d/c2pa.signature"
+ },
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:03e0b8ea-59e3-7669-09eb-4449e66c5f3d/c2pa.signature"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:03e0b8ea-59e3-7669-09eb-4449e66c5f3d/c2pa.signature"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:03e0b8ea-59e3-7669-09eb-4449e66c5f3d/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:03e0b8ea-59e3-7669-09eb-4449e66c5f3d/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:03e0b8ea-59e3-7669-09eb-4449e66c5f3d/c2pa.signature"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:03e0b8ea-59e3-7669-09eb-4449e66c5f3d/c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:03e0b8ea-59e3-7669-09eb-4449e66c5f3d/c2pa.assertions/c2pa.hash.data"
+ },
+ {
+ "code": "assertion.dataHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:03e0b8ea-59e3-7669-09eb-4449e66c5f3d/c2pa.assertions/c2pa.hash.data"
+ }
+ ],
+ "informational": [],
+ "failure": []
+ }
+ },
+ "activeManifest": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:03e0b8ea-59e3-7669-09eb-4449e66c5f3d",
+ "hash": "b64'uDaidLyepRSYH/kfn97OAShDg2IC5805d1sWfirCpgw=",
+ "pad": "b64'"
+ },
+ "claimSignature": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:03e0b8ea-59e3-7669-09eb-4449e66c5f3d/c2pa.signature",
+ "hash": "b64'GbT/lBL9C12zO2a/W8G3plqEAbvEBJRDqz6xopvsOUc=",
+ "pad": "b64'"
+ },
+ "description": "Input ingredient 0"
+ }
+ },
+ "claim.v2": {
+ "instanceID": "04f38790-bbf0-76c4-942a-4be47a16800b",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:c9d683be-dbbc-3000-ff87-4c8041d75d47/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA Core Generator Library",
+ "version": "875320944:877502141"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'WeQCfBdSGx8QUYVPUetAeTjuFJr7Nb0Iil2P3YeRvsI="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'4irV6HAYABnKSvdtS+zIgo1p1u1SybldjlNz/HxIZAU="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.data",
+ "hash": "b64'MCV86f5RmGhMOmSQKXP47qB0zzqiwCZo03fjZLWE8Ls="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "9eaf156281a94902165b48ff58a96d24981ece",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Media Services 1P ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "OU": "Google System 60032",
+ "CN": "Google Media Processing Services"
+ },
+ "validity": {
+ "notBefore": "2026-02-17T15:17:12+00:00",
+ "notAfter": "2027-02-12T15:17:11+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2026-03-04T01:35:16+00:00",
+ "certificateInfo": {
+ "serialNumber": "b2d5d51f21675e13bc4130aa88bc3be4e0ba1e",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Core Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google Core Time Stamping Authority T9"
+ },
+ "validity": {
+ "notBefore": "2025-09-08T13:48:55+00:00",
+ "notAfter": "2031-09-09T01:48:54+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [
+ {
+ "code": "ingredient.manifest.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:c9d683be-dbbc-3000-ff87-4c8041d75d47",
+ "explanation": "ingredient hash matched"
+ }
+ ],
+ "informational": [
+ {
+ "code": "signingCredential.ocsp.notRevoked",
+ "url": "self#jumbf=/c2pa/urn:c2pa:c9d683be-dbbc-3000-ff87-4c8041d75d47/c2pa.signature",
+ "explanation": "signing cert not revoked: 3538769668273185232431161852199326737017347790"
+ }
+ ],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-03-31T06:51:17.190+00:00"
+ }
+ },
+ {
+ "label": "urn:c2pa:03e0b8ea-59e3-7669-09eb-4449e66c5f3d",
+ "assertions": {
+ "c2pa.hash.data": {
+ "exclusions": [
+ {
+ "start": 20,
+ "length": 6028
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'jwPUX7kNiYzwM0ACsefmQ/8D0ylBj8mb4Gch3x0jvso=",
+ "pad": "b64'AAAAAAAAAAAAAAAAAAA="
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.created",
+ "description": "Created by Google Generative AI.",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"
+ },
+ {
+ "action": "c2pa.edited",
+ "description": "Applied imperceptible SynthID watermark.",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"
+ }
+ ]
+ }
+ },
+ "claim.v2": {
+ "instanceID": "89809671-03bb-f3d7-be93-411ef0d1b9bb",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:03e0b8ea-59e3-7669-09eb-4449e66c5f3d/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA Core Generator Library",
+ "version": "875320944:877502141"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'aCJRK8tzlB0JC+7AU+n6ttA6XAirm7RJDKP1WkTQ6Lk="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.data",
+ "hash": "b64'LGba50dR/zcUwB9a8jSOSyVfq/ydrC9U7t8Z3ywwFQM="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "9eaf156281a94902165b48ff58a96d24981ece",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Media Services 1P ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "OU": "Google System 60032",
+ "CN": "Google Media Processing Services"
+ },
+ "validity": {
+ "notBefore": "2026-02-17T15:17:12+00:00",
+ "notAfter": "2027-02-12T15:17:11+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2026-03-04T01:34:18+00:00",
+ "certificateInfo": {
+ "serialNumber": "6c26eeedd09cdcec7670d543e6da504e3a9c5e",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Core Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google Core Time Stamping Authority T12"
+ },
+ "validity": {
+ "notBefore": "2025-09-08T13:49:00+00:00",
+ "notAfter": "2031-09-09T01:48:59+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [],
+ "informational": [],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-03-31T06:51:17.191+00:00"
+ }
+ }
+ ],
+ "jsonGenerator": {
+ "name": "c2pa-rs",
+ "version": "0.78.0"
+ },
+ "usedITL": false,
+ "usedTestCerts": false,
+ "_conformanceToolVersion": {
+ "commit": "d291fb884e02665d169a38928392c2710f6f0953",
+ "shortCommit": "d291fb8",
+ "date": "2026-03-19 20:11:48 -0400",
+ "branch": "HEAD",
+ "generatedAt": "2026-03-23T18:42:00.448Z"
+ }
+}
\ No newline at end of file
diff --git a/src/lib/rubrics/__fixtures__/t2i-gemini.signals.json b/src/lib/rubrics/__fixtures__/t2i-gemini.signals.json
new file mode 100644
index 0000000..8b89dbe
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/t2i-gemini.signals.json
@@ -0,0 +1,97 @@
+{
+ "rubricName": "C2PA Asset Signals Rubric (Local)",
+ "rubricVersion": "1.0.0",
+ "manifests": [
+ {
+ "assertedBy": {
+ "CN": "Google Media Processing Services",
+ "O": "Google LLC",
+ "OU": "Google System 67154"
+ },
+ "mimeType": null,
+ "localInceptions": [],
+ "localTransformations": [
+ {
+ "trait": "transformation:signal_editorialAI",
+ "reportText": "Contains Editorial GenAI Transformations",
+ "multiple": false
+ },
+ {
+ "trait": "transformation:signal_editorialNonAI",
+ "reportText": "Contains Editorial Non-GenAI Transformations",
+ "multiple": false
+ },
+ {
+ "trait": "transformation:signal_editorialPossiblyGenAI",
+ "reportText": "Contains Editorial Transformations Possibly Using GenAI",
+ "multiple": false
+ },
+ {
+ "trait": "transformation:signal_nonEditorial",
+ "reportText": "Contains Non-editorial Transformations",
+ "multiple": false
+ }
+ ],
+ "allActionsIncluded": false,
+ "ingredients": [
+ {
+ "index": 1,
+ "relationship": "parentOf"
+ }
+ ]
+ },
+ {
+ "assertedBy": {
+ "CN": "Google Media Processing Services",
+ "O": "Google LLC",
+ "OU": "Google System 60032"
+ },
+ "mimeType": null,
+ "localInceptions": [
+ {
+ "trait": "inception:signal_fullyGenAIMedia",
+ "reportText": "Contains Fully GenAI Media",
+ "multiple": false
+ }
+ ],
+ "localTransformations": [
+ {
+ "trait": "transformation:signal_editorialAI",
+ "reportText": "Contains Editorial GenAI Transformations",
+ "multiple": false
+ }
+ ],
+ "allActionsIncluded": false,
+ "ingredients": [
+ {
+ "index": 2,
+ "relationship": "inputTo"
+ }
+ ]
+ },
+ {
+ "assertedBy": {
+ "CN": "Google Media Processing Services",
+ "O": "Google LLC",
+ "OU": "Google System 60032"
+ },
+ "mimeType": "image/jpeg",
+ "localInceptions": [
+ {
+ "trait": "inception:signal_fullyGenAIMedia",
+ "reportText": "Contains Fully GenAI Media",
+ "multiple": false
+ }
+ ],
+ "localTransformations": [
+ {
+ "trait": "transformation:signal_editorialAI",
+ "reportText": "Contains Editorial GenAI Transformations",
+ "multiple": false
+ }
+ ],
+ "allActionsIncluded": false,
+ "ingredients": []
+ }
+ ]
+}
diff --git a/src/lib/rubrics/__fixtures__/t2i-openai.conformance.json b/src/lib/rubrics/__fixtures__/t2i-openai.conformance.json
new file mode 100644
index 0000000..1494366
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/t2i-openai.conformance.json
@@ -0,0 +1,72 @@
+{
+ "rubricName": "C2PA Asset Conformance 0.1 Spec 2.2 Rubric",
+ "rubricVersion": "0.1.0",
+ "true": {
+ "validation": [
+ {
+ "trait": "active_manifest_urn",
+ "reportText": "Active manifest URN uses standard 'urn:c2pa:' prefix"
+ },
+ {
+ "trait": "inception_action_position",
+ "reportText": "Inception action is correctly positioned as the first item in the first created actions assertion"
+ },
+ {
+ "trait": "ingredient_relationship_values",
+ "reportText": "All ingredient assertions have valid relationship values"
+ },
+ {
+ "trait": "ingredient_v3_no_active_manifest",
+ "reportText": "Ingredient v3 assertions without activeManifest correctly omit validationResults"
+ },
+ {
+ "trait": "no_deprecated_actions",
+ "reportText": "No deprecated actions used"
+ },
+ {
+ "trait": "no_deprecated_assertions",
+ "reportText": "No deprecated standard assertions found"
+ },
+ {
+ "trait": "no_unsupported_assertions",
+ "reportText": "All standard assertions are supported for Spec 2.2"
+ },
+ {
+ "trait": "review_ratings_datasource",
+ "reportText": "No invalid co-occurrence of reviewRatings and human entry dataSource"
+ },
+ {
+ "trait": "trusted_data_present",
+ "reportText": "Validation results are present for trust analysis"
+ },
+ {
+ "trait": "update_manifest_constraints",
+ "reportText": "Update manifest constraints are satisfied (or manifest is not an update manifest)"
+ },
+ {
+ "trait": "valid_data_present",
+ "reportText": "Validation results are present for integrity analysis"
+ },
+ {
+ "trait": "valid_success",
+ "reportText": "No validation mismatches found"
+ },
+ {
+ "trait": "well_formed_data_present",
+ "reportText": "Validation results are present for structural analysis"
+ },
+ {
+ "trait": "well_formed_success",
+ "reportText": "No structural failures found"
+ }
+ ]
+ },
+ "false": {
+ "validation": [
+ {
+ "trait": "trusted_success",
+ "reportText": "Found trust failures: signingCredential.untrusted"
+ }
+ ]
+ }
+}
diff --git a/src/lib/rubrics/__fixtures__/t2i-openai.json b/src/lib/rubrics/__fixtures__/t2i-openai.json
new file mode 100644
index 0000000..8469b1c
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/t2i-openai.json
@@ -0,0 +1,330 @@
+{
+ "@context": {
+ "@vocab": "https://contentcredentials.org/crjson",
+ "extras": "https://contentcredentials.org/crjson/extras"
+ },
+ "manifests": [
+ {
+ "label": "urn:c2pa:e274ecd0-34ec-46b3-aa9d-d90dc10aa56a",
+ "assertions": {
+ "c2pa.thumbnail.ingredient": {
+ "format": "image/jpeg",
+ "identifier": "self#jumbf=/c2pa/urn:c2pa:e274ecd0-34ec-46b3-aa9d-d90dc10aa56a/c2pa.assertions/c2pa.thumbnail.ingredient",
+ "hash": "b64'rjYkR3i4FhakkodD6cqv392O79bjOuGQhfbYPh/AN+w="
+ },
+ "c2pa.ingredient.v3": {
+ "relationship": "parentOf",
+ "dc:title": "image.png",
+ "dc:format": "png",
+ "validationResults": {
+ "activeManifest": {
+ "success": [
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:adf57efd-c6bf-4ee2-9d3e-0f473a8ff16c/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:adf57efd-c6bf-4ee2-9d3e-0f473a8ff16c/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:adf57efd-c6bf-4ee2-9d3e-0f473a8ff16c/c2pa.assertions/c2pa.actions.v2",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:adf57efd-c6bf-4ee2-9d3e-0f473a8ff16c/c2pa.assertions/c2pa.hash.data",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.hash.data"
+ },
+ {
+ "code": "assertion.dataHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:adf57efd-c6bf-4ee2-9d3e-0f473a8ff16c/c2pa.assertions/c2pa.hash.data",
+ "explanation": "data hash valid"
+ }
+ ],
+ "informational": [],
+ "failure": []
+ }
+ },
+ "instanceID": "xmp:iid:8c614a03-e296-4ff9-ad28-dc43088ac657",
+ "activeManifest": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:adf57efd-c6bf-4ee2-9d3e-0f473a8ff16c",
+ "alg": "sha256",
+ "hash": "b64'TqZVTHsaDs4JonPfiaVbML7udhg4TAbcScqdudc5dk4=",
+ "pad": "b64'"
+ },
+ "claimSignature": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:adf57efd-c6bf-4ee2-9d3e-0f473a8ff16c/c2pa.signature",
+ "alg": "sha256",
+ "hash": "b64'7rpHoq0UDMcas1hAPjBtSFdRoKPeszatNICO4k8lipo=",
+ "pad": "b64'"
+ },
+ "thumbnail": {
+ "url": "self#jumbf=c2pa.assertions/c2pa.thumbnail.ingredient",
+ "hash": "b64'rjYkR3i4FhakkodD6cqv392O79bjOuGQhfbYPh/AN+w=",
+ "pad": "b64'"
+ }
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.opened",
+ "parameters": {
+ "ingredients": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'GegYoWtgLHvOMUDooV9zuEnhsrPVKVDnW4XLXf7xUYo=",
+ "pad": "b64'"
+ }
+ ]
+ }
+ }
+ ]
+ },
+ "c2pa.hash.data": {
+ "exclusions": [
+ {
+ "start": 33,
+ "length": 71711
+ }
+ ],
+ "name": "jumbf manifest",
+ "alg": "sha256",
+ "hash": "b64'wy0JUOnYycBpEP1AkiRsBYQj8oJxf6iUDbj7tE8nuFk=",
+ "pad": "b64'AAAAAAAAAAA="
+ }
+ },
+ "claim.v2": {
+ "instanceID": "xmp:iid:f4e73b28-f7ed-4f73-aa2b-6b5cccd1dbb4",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:e274ecd0-34ec-46b3-aa9d-d90dc10aa56a/c2pa.signature",
+ "claim_generator_info": {
+ "name": "ChatGPT",
+ "org.contentauth.c2pa_rs": "0.0.0"
+ },
+ "alg": "sha256",
+ "dc:title": "image.png",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.thumbnail.ingredient",
+ "hash": "b64'rjYkR3i4FhakkodD6cqv392O79bjOuGQhfbYPh/AN+w="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'GegYoWtgLHvOMUDooV9zuEnhsrPVKVDnW4XLXf7xUYo="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'ocxu/8itRySQo/13V/3ve4jmsLQThuRYEHZT25PD5F8="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.data",
+ "hash": "b64'4JnHtQ0bQQuSqf0yALwpoUOHR49mWGcf6/E9Sfg+LqQ="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "6c29a373fbdcc1d6bb48fc34ba5efa4004e0c446",
+ "issuer": {
+ "CN": "WebClaimSigningCA",
+ "OU": "Lens",
+ "O": "Truepic",
+ "C": "US"
+ },
+ "subject": {
+ "C": "US",
+ "O": "OpenAI",
+ "OU": "Sora",
+ "CN": "Truepic Lens CLI in Sora"
+ },
+ "validity": {
+ "notBefore": "2025-04-15T15:09:05+00:00",
+ "notAfter": "2026-04-15T15:09:04+00:00"
+ }
+ }
+ },
+ "validationResults": {
+ "success": [
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:e274ecd0-34ec-46b3-aa9d-d90dc10aa56a/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:e274ecd0-34ec-46b3-aa9d-d90dc10aa56a/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:e274ecd0-34ec-46b3-aa9d-d90dc10aa56a/c2pa.assertions/c2pa.thumbnail.ingredient",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.thumbnail.ingredient"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:e274ecd0-34ec-46b3-aa9d-d90dc10aa56a/c2pa.assertions/c2pa.ingredient.v3",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:e274ecd0-34ec-46b3-aa9d-d90dc10aa56a/c2pa.assertions/c2pa.actions.v2",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:e274ecd0-34ec-46b3-aa9d-d90dc10aa56a/c2pa.assertions/c2pa.hash.data",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.hash.data"
+ },
+ {
+ "code": "assertion.dataHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:e274ecd0-34ec-46b3-aa9d-d90dc10aa56a/c2pa.assertions/c2pa.hash.data",
+ "explanation": "data hash valid"
+ }
+ ],
+ "informational": [],
+ "failure": [
+ {
+ "code": "signingCredential.untrusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:e274ecd0-34ec-46b3-aa9d-d90dc10aa56a/c2pa.signature",
+ "explanation": "signing certificate untrusted"
+ }
+ ],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-03-31T05:51:57.503+00:00"
+ },
+ "ingredientDeltas": [
+ {
+ "ingredientAssertionURI": "self#jumbf=/c2pa/urn:c2pa:e274ecd0-34ec-46b3-aa9d-d90dc10aa56a/c2pa.assertions/c2pa.ingredient.v3",
+ "validationDeltas": {
+ "success": [
+ {
+ "code": "ingredient.manifest.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:adf57efd-c6bf-4ee2-9d3e-0f473a8ff16c",
+ "explanation": "ingredient hash matched"
+ }
+ ],
+ "informational": [],
+ "failure": [
+ {
+ "code": "signingCredential.untrusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:adf57efd-c6bf-4ee2-9d3e-0f473a8ff16c/c2pa.signature",
+ "explanation": "signing certificate untrusted"
+ }
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "label": "urn:c2pa:adf57efd-c6bf-4ee2-9d3e-0f473a8ff16c",
+ "assertions": {
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.created",
+ "softwareAgent": {
+ "name": "GPT-4o"
+ },
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"
+ },
+ {
+ "action": "c2pa.converted"
+ }
+ ]
+ },
+ "c2pa.hash.data": {
+ "exclusions": [
+ {
+ "start": 33,
+ "length": 14118
+ }
+ ],
+ "name": "jumbf manifest",
+ "alg": "sha256",
+ "hash": "b64'wy0JUOnYycBpEP1AkiRsBYQj8oJxf6iUDbj7tE8nuFk=",
+ "pad": "b64'AAAAAAAAAAA="
+ }
+ },
+ "claim.v2": {
+ "instanceID": "xmp:iid:4a3c2a89-6c9c-4f46-bb0d-fd201c8d9957",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:adf57efd-c6bf-4ee2-9d3e-0f473a8ff16c/c2pa.signature",
+ "claim_generator_info": {
+ "name": "ChatGPT",
+ "org.contentauth.c2pa_rs": "0.0.0"
+ },
+ "alg": "sha256",
+ "dc:title": "image.png",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'j06jKi2a0YnaYj1JQTJA/fXaKbmaViQasY2WYTITPLU="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.data",
+ "hash": "b64'hsxg8DbXsyiU7mFA+DF56T8dgofZbPNplpHuRg36ya4="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "6c29a373fbdcc1d6bb48fc34ba5efa4004e0c446",
+ "issuer": {
+ "CN": "WebClaimSigningCA",
+ "OU": "Lens",
+ "O": "Truepic",
+ "C": "US"
+ },
+ "subject": {
+ "C": "US",
+ "O": "OpenAI",
+ "OU": "Sora",
+ "CN": "Truepic Lens CLI in Sora"
+ },
+ "validity": {
+ "notBefore": "2025-04-15T15:09:05+00:00",
+ "notAfter": "2026-04-15T15:09:04+00:00"
+ }
+ }
+ },
+ "validationResults": {
+ "success": [
+ {
+ "code": "ingredient.manifest.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:adf57efd-c6bf-4ee2-9d3e-0f473a8ff16c",
+ "explanation": "ingredient hash matched"
+ }
+ ],
+ "informational": [],
+ "failure": [
+ {
+ "code": "signingCredential.untrusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:adf57efd-c6bf-4ee2-9d3e-0f473a8ff16c/c2pa.signature",
+ "explanation": "signing certificate untrusted"
+ }
+ ],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-03-31T05:51:57.503+00:00"
+ }
+ }
+ ],
+ "jsonGenerator": {
+ "name": "c2pa-rs",
+ "version": "0.78.0"
+ },
+ "usedITL": false,
+ "usedTestCerts": false,
+ "_conformanceToolVersion": {
+ "commit": "d291fb884e02665d169a38928392c2710f6f0953",
+ "shortCommit": "d291fb8",
+ "date": "2026-03-19 20:11:48 -0400",
+ "branch": "HEAD",
+ "generatedAt": "2026-03-23T18:42:00.448Z"
+ }
+}
\ No newline at end of file
diff --git a/src/lib/rubrics/__fixtures__/t2i-openai.signals.json b/src/lib/rubrics/__fixtures__/t2i-openai.signals.json
new file mode 100644
index 0000000..40ed096
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/t2i-openai.signals.json
@@ -0,0 +1,47 @@
+{
+ "rubricName": "C2PA Asset Signals Rubric (Local)",
+ "rubricVersion": "1.0.0",
+ "manifests": [
+ {
+ "assertedBy": {
+ "CN": "Truepic Lens CLI in Sora",
+ "O": "OpenAI",
+ "OU": "Sora"
+ },
+ "mimeType": "image/jpeg",
+ "localInceptions": [],
+ "localTransformations": [],
+ "allActionsIncluded": false,
+ "ingredients": [
+ {
+ "index": 1,
+ "relationship": "parentOf"
+ }
+ ]
+ },
+ {
+ "assertedBy": {
+ "CN": "Truepic Lens CLI in Sora",
+ "O": "OpenAI",
+ "OU": "Sora"
+ },
+ "mimeType": "png",
+ "localInceptions": [
+ {
+ "trait": "inception:signal_fullyGenAIMedia",
+ "reportText": "Contains Fully GenAI Media",
+ "multiple": false
+ }
+ ],
+ "localTransformations": [
+ {
+ "trait": "transformation:signal_nonEditorial",
+ "reportText": "Contains Non-editorial Transformations",
+ "multiple": false
+ }
+ ],
+ "allActionsIncluded": false,
+ "ingredients": []
+ }
+ ]
+}
diff --git a/src/lib/rubrics/__fixtures__/t2i-plus.conformance.json b/src/lib/rubrics/__fixtures__/t2i-plus.conformance.json
new file mode 100644
index 0000000..c17a9eb
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/t2i-plus.conformance.json
@@ -0,0 +1,71 @@
+{
+ "rubricName": "C2PA Asset Conformance 0.1 Spec 2.2 Rubric",
+ "rubricVersion": "0.1.0",
+ "true": {
+ "validation": [
+ {
+ "trait": "active_manifest_urn",
+ "reportText": "Active manifest URN uses standard 'urn:c2pa:' prefix"
+ },
+ {
+ "trait": "inception_action_position",
+ "reportText": "Inception action is correctly positioned as the first item in the first created actions assertion"
+ },
+ {
+ "trait": "ingredient_relationship_values",
+ "reportText": "All ingredient assertions have valid relationship values"
+ },
+ {
+ "trait": "ingredient_v3_no_active_manifest",
+ "reportText": "Ingredient v3 assertions without activeManifest correctly omit validationResults"
+ },
+ {
+ "trait": "no_deprecated_actions",
+ "reportText": "No deprecated actions used"
+ },
+ {
+ "trait": "no_deprecated_assertions",
+ "reportText": "No deprecated standard assertions found"
+ },
+ {
+ "trait": "no_unsupported_assertions",
+ "reportText": "All standard assertions are supported for Spec 2.2"
+ },
+ {
+ "trait": "review_ratings_datasource",
+ "reportText": "No invalid co-occurrence of reviewRatings and human entry dataSource"
+ },
+ {
+ "trait": "trusted_data_present",
+ "reportText": "Validation results are present for trust analysis"
+ },
+ {
+ "trait": "trusted_success",
+ "reportText": "Asset is trusted"
+ },
+ {
+ "trait": "update_manifest_constraints",
+ "reportText": "Update manifest constraints are satisfied (or manifest is not an update manifest)"
+ },
+ {
+ "trait": "valid_data_present",
+ "reportText": "Validation results are present for integrity analysis"
+ },
+ {
+ "trait": "valid_success",
+ "reportText": "No validation mismatches found"
+ },
+ {
+ "trait": "well_formed_data_present",
+ "reportText": "Validation results are present for structural analysis"
+ },
+ {
+ "trait": "well_formed_success",
+ "reportText": "No structural failures found"
+ }
+ ]
+ },
+ "false": {
+ "validation": []
+ }
+}
diff --git a/src/lib/rubrics/__fixtures__/t2i-plus.json b/src/lib/rubrics/__fixtures__/t2i-plus.json
new file mode 100644
index 0000000..b606e87
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/t2i-plus.json
@@ -0,0 +1,1378 @@
+{
+ "@context": {
+ "@vocab": "https://contentcredentials.org/crjson",
+ "extras": "https://contentcredentials.org/crjson/extras"
+ },
+ "manifests": [
+ {
+ "label": "urn:c2pa:840f88ed-cc8b-c4d9-ded4-4b41633a8371",
+ "assertions": {
+ "c2pa.hash.boxes": {
+ "boxes": [
+ {
+ "names": [
+ "PNGh",
+ "IHDR",
+ "iTXt"
+ ],
+ "hash": "b64'UAn6NvKU888EjzhqrP0Gjnbqledw4/pu1jAbm3FGk3M=",
+ "pad": "b64'",
+ "pad2": "b64'"
+ },
+ {
+ "names": [
+ "C2PA"
+ ],
+ "hash": "b64'AA==",
+ "pad": "b64'",
+ "pad2": "b64'"
+ },
+ {
+ "names": [
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IDAT",
+ "IEND"
+ ],
+ "hash": "b64'um7jea+l3NiG0mjxptpK8gzGU1R7Fy+Wk2Yxb52HDkg=",
+ "pad": "b64'",
+ "pad2": "b64'"
+ }
+ ],
+ "alg": "sha256"
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.opened",
+ "parameters": {
+ "ingredients": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'zUGzL5QIbeApvXcsdkb8zXhKUYICckoRSlxUOurr4Qc=",
+ "pad": "b64'"
+ }
+ ]
+ }
+ },
+ {
+ "action": "c2pa.edited",
+ "description": "Added imperceptible SynthID watermark",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"
+ },
+ {
+ "action": "c2pa.edited",
+ "description": "Added visible watermark",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/composite"
+ },
+ {
+ "action": "c2pa.converted",
+ "description": "Converted to .png"
+ }
+ ]
+ },
+ "c2pa.ingredient.v3": {
+ "relationship": "parentOf",
+ "validationResults": {
+ "activeManifest": {
+ "success": [
+ {
+ "code": "signingCredential.ocsp.notRevoked",
+ "url": "self#jumbf=/c2pa/urn:c2pa:964aabeb-447d-453a-f767-455d60eb8f09/c2pa.signature"
+ },
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:964aabeb-447d-453a-f767-455d60eb8f09/c2pa.signature"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:964aabeb-447d-453a-f767-455d60eb8f09/c2pa.signature"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:964aabeb-447d-453a-f767-455d60eb8f09/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:964aabeb-447d-453a-f767-455d60eb8f09/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:964aabeb-447d-453a-f767-455d60eb8f09/c2pa.signature"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:964aabeb-447d-453a-f767-455d60eb8f09/c2pa.assertions/c2pa.ingredient.v3"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:964aabeb-447d-453a-f767-455d60eb8f09/c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:964aabeb-447d-453a-f767-455d60eb8f09/c2pa.assertions/c2pa.hash.data"
+ },
+ {
+ "code": "ingredient.claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:964aabeb-447d-453a-f767-455d60eb8f09/c2pa.assertions/c2pa.ingredient.v3"
+ },
+ {
+ "code": "assertion.dataHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:964aabeb-447d-453a-f767-455d60eb8f09/c2pa.assertions/c2pa.hash.data"
+ }
+ ],
+ "informational": [],
+ "failure": []
+ },
+ "ingredientDeltas": [
+ {
+ "ingredientAssertionURI": "self#jumbf=/c2pa/urn:c2pa:964aabeb-447d-453a-f767-455d60eb8f09/c2pa.assertions/c2pa.ingredient.v3",
+ "validationDeltas": {
+ "success": [],
+ "informational": [],
+ "failure": []
+ }
+ }
+ ]
+ },
+ "activeManifest": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:964aabeb-447d-453a-f767-455d60eb8f09",
+ "hash": "b64'NhbmtLPvGKUK49B8JO64rPiuNUbup+kxl9ntq/Y98Ks=",
+ "pad": "b64'"
+ },
+ "claimSignature": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:964aabeb-447d-453a-f767-455d60eb8f09/c2pa.signature",
+ "hash": "b64'QcCN4Um3G4WElKSIORMyKdGlfTQ6C7b5cxF7el+IeVw=",
+ "pad": "b64'"
+ }
+ }
+ },
+ "claim.v2": {
+ "instanceID": "4cfcb3b4-b210-4d7e-4555-4c6b853ccb69",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:840f88ed-cc8b-c4d9-ded4-4b41633a8371/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA Core Generator Library",
+ "version": "839267052:839267052"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'zUGzL5QIbeApvXcsdkb8zXhKUYICckoRSlxUOurr4Qc="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'bnUpvMC335K44qfKeOzD71ljKMP6IE7U63HHF0Ic2vg="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.boxes",
+ "hash": "b64'1vGD7QlmodKqQ26fqbRcJvp5msLSuq77ujvSP5/Xam4="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "861352a867db32ba70726ad50ed2e2b3b4ead2",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Media Services 1P ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "OU": "Google System 67154",
+ "CN": "Google Media Processing Services"
+ },
+ "validity": {
+ "notBefore": "2025-07-30T10:58:39+00:00",
+ "notAfter": "2026-01-26T10:58:38+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2025-12-05T15:16:03+00:00",
+ "certificateInfo": {
+ "serialNumber": "7b519970ffd75a959d0c40d74e86f1d7702483",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Core Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google Core Time Stamping Authority T11"
+ },
+ "validity": {
+ "notBefore": "2025-09-08T13:48:59+00:00",
+ "notAfter": "2031-09-09T01:48:58+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:840f88ed-cc8b-c4d9-ded4-4b41633a8371/c2pa.signature",
+ "explanation": "timestamp message digest matched: Google Core Time Stamping Authority T11"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:840f88ed-cc8b-c4d9-ded4-4b41633a8371/c2pa.signature",
+ "explanation": "timestamp cert trusted: Google Core Time Stamping Authority T11"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:840f88ed-cc8b-c4d9-ded4-4b41633a8371/c2pa.signature",
+ "explanation": "signing certificate trusted, found in System trust anchors"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:840f88ed-cc8b-c4d9-ded4-4b41633a8371/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:840f88ed-cc8b-c4d9-ded4-4b41633a8371/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:840f88ed-cc8b-c4d9-ded4-4b41633a8371/c2pa.assertions/c2pa.ingredient.v3",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:840f88ed-cc8b-c4d9-ded4-4b41633a8371/c2pa.assertions/c2pa.actions.v2",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:840f88ed-cc8b-c4d9-ded4-4b41633a8371/c2pa.assertions/c2pa.hash.boxes",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.hash.boxes"
+ },
+ {
+ "code": "assertion.boxesHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:840f88ed-cc8b-c4d9-ded4-4b41633a8371/c2pa.assertions/c2pa.hash.boxes",
+ "explanation": "boxes hash valid"
+ }
+ ],
+ "informational": [
+ {
+ "code": "signingCredential.ocsp.notRevoked",
+ "url": "self#jumbf=/c2pa/urn:c2pa:840f88ed-cc8b-c4d9-ded4-4b41633a8371/c2pa.signature",
+ "explanation": "signing cert not revoked: 2989983117039450197712507261014569595627170514"
+ }
+ ],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-03-31T04:16:15.411+00:00"
+ },
+ "ingredientDeltas": [
+ {
+ "ingredientAssertionURI": "self#jumbf=/c2pa/urn:c2pa:840f88ed-cc8b-c4d9-ded4-4b41633a8371/c2pa.assertions/c2pa.ingredient.v3",
+ "validationDeltas": {
+ "success": [
+ {
+ "code": "ingredient.manifest.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:964aabeb-447d-453a-f767-455d60eb8f09",
+ "explanation": "ingredient hash matched"
+ }
+ ],
+ "informational": [
+ {
+ "code": "signingCredential.ocsp.notRevoked",
+ "url": "self#jumbf=/c2pa/urn:c2pa:964aabeb-447d-453a-f767-455d60eb8f09/c2pa.signature",
+ "explanation": "signing cert not revoked: 2848987360631057089626022484736785301855189030"
+ }
+ ],
+ "failure": []
+ }
+ }
+ ]
+ },
+ {
+ "label": "urn:c2pa:964aabeb-447d-453a-f767-455d60eb8f09",
+ "assertions": {
+ "c2pa.hash.data": {
+ "exclusions": [
+ {
+ "start": 20,
+ "length": 13689
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'mA6cnDTpCb6ci4LvKrzhx5fsxWfqLH3aGQE5jLXIpk0=",
+ "pad": "b64'AAAAAAAAAAAAAAAAAAA="
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.created",
+ "description": "Created by Google Generative AI.",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia",
+ "parameters": {
+ "ingredients": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'ndFGLQ/R4urVZaXE/9RKp1TbDoOJ8y46lqugAG7EUZY=",
+ "pad": "b64'"
+ }
+ ]
+ }
+ }
+ ]
+ },
+ "c2pa.ingredient.v3": {
+ "relationship": "inputTo",
+ "dc:format": "image/jpeg",
+ "validationResults": {
+ "activeManifest": {
+ "success": [
+ {
+ "code": "signingCredential.ocsp.notRevoked",
+ "url": "self#jumbf=/c2pa/urn:c2pa:dfdfab00-b60a-9324-d754-4b43b6dfd0d4/c2pa.signature"
+ },
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:dfdfab00-b60a-9324-d754-4b43b6dfd0d4/c2pa.signature"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:dfdfab00-b60a-9324-d754-4b43b6dfd0d4/c2pa.signature"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:dfdfab00-b60a-9324-d754-4b43b6dfd0d4/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:dfdfab00-b60a-9324-d754-4b43b6dfd0d4/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:dfdfab00-b60a-9324-d754-4b43b6dfd0d4/c2pa.signature"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:dfdfab00-b60a-9324-d754-4b43b6dfd0d4/c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:dfdfab00-b60a-9324-d754-4b43b6dfd0d4/c2pa.assertions/c2pa.hash.data"
+ },
+ {
+ "code": "assertion.dataHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:dfdfab00-b60a-9324-d754-4b43b6dfd0d4/c2pa.assertions/c2pa.hash.data"
+ }
+ ],
+ "informational": [],
+ "failure": []
+ }
+ },
+ "activeManifest": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:dfdfab00-b60a-9324-d754-4b43b6dfd0d4",
+ "hash": "b64'UB/dPy1WQQka/4eQf0QBKZyKyuEEUGqBTWIodIwNdXE=",
+ "pad": "b64'"
+ },
+ "claimSignature": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:dfdfab00-b60a-9324-d754-4b43b6dfd0d4/c2pa.signature",
+ "hash": "b64'0scUoK5Igplaqe8YzY3IWsFxVN7Y7oPRSFEPle/eGxY=",
+ "pad": "b64'"
+ },
+ "description": "Input ingredient 0"
+ }
+ },
+ "claim.v2": {
+ "instanceID": "d1eb9e0e-d0a1-cb9f-d729-4e9f3a28da29",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:964aabeb-447d-453a-f767-455d60eb8f09/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA Core Generator Library",
+ "version": "839419279:839419279"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'ndFGLQ/R4urVZaXE/9RKp1TbDoOJ8y46lqugAG7EUZY="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'/5tLIovgZI7o1BGqV3OtWt2kVH6VOqO8HynYl1dAxBU="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.data",
+ "hash": "b64'NBOukQ9msadngJnTCpPvC0qcDj3ysCPXfGS7iUiJLXQ="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "7fc0c55eb602ce830f9dfdd04ab00c459eb826",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Media Services 1P ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "OU": "Google System 60032",
+ "CN": "Google Media Processing Services"
+ },
+ "validity": {
+ "notBefore": "2025-10-30T22:34:47+00:00",
+ "notAfter": "2026-10-25T22:34:46+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2025-12-05T15:16:00+00:00",
+ "certificateInfo": {
+ "serialNumber": "7b519970ffd75a959d0c40d74e86f1d7702483",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Core Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google Core Time Stamping Authority T11"
+ },
+ "validity": {
+ "notBefore": "2025-09-08T13:48:59+00:00",
+ "notAfter": "2031-09-09T01:48:58+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [
+ {
+ "code": "ingredient.manifest.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:964aabeb-447d-453a-f767-455d60eb8f09",
+ "explanation": "ingredient hash matched"
+ }
+ ],
+ "informational": [
+ {
+ "code": "signingCredential.ocsp.notRevoked",
+ "url": "self#jumbf=/c2pa/urn:c2pa:964aabeb-447d-453a-f767-455d60eb8f09/c2pa.signature",
+ "explanation": "signing cert not revoked: 2848987360631057089626022484736785301855189030"
+ }
+ ],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-03-31T04:16:15.411+00:00"
+ }
+ },
+ {
+ "label": "urn:c2pa:dfdfab00-b60a-9324-d754-4b43b6dfd0d4",
+ "assertions": {
+ "c2pa.hash.data": {
+ "exclusions": [
+ {
+ "start": 20,
+ "length": 5991
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'o1R7dQ1vZ9tUvnnxcKK5+6RHin56UBJQiIM3fADDeeM=",
+ "pad": "b64'AAAAAAAAAAAAAAAAAAA="
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.created",
+ "description": "Created by Google Generative AI.",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"
+ }
+ ]
+ }
+ },
+ "claim.v2": {
+ "instanceID": "793da6ce-4f26-2ecd-100a-4956742768a7",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:dfdfab00-b60a-9324-d754-4b43b6dfd0d4/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA Core Generator Library",
+ "version": "839419279:839419279"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'0iRpWnXaCPtJwlIc8RruokSxpMzbiXQ7NBM1qAtHaWY="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.data",
+ "hash": "b64'CGdetlWF4JnbDL2p2SGAC5kcoaBXTxm7R218V+UbCX8="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "7fc0c55eb602ce830f9dfdd04ab00c459eb826",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Media Services 1P ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "OU": "Google System 60032",
+ "CN": "Google Media Processing Services"
+ },
+ "validity": {
+ "notBefore": "2025-10-30T22:34:47+00:00",
+ "notAfter": "2026-10-25T22:34:46+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2025-12-05T15:15:34+00:00",
+ "certificateInfo": {
+ "serialNumber": "7b519970ffd75a959d0c40d74e86f1d7702483",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Core Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google Core Time Stamping Authority T11"
+ },
+ "validity": {
+ "notBefore": "2025-09-08T13:48:59+00:00",
+ "notAfter": "2031-09-09T01:48:58+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [],
+ "informational": [],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-03-31T04:16:15.412+00:00"
+ }
+ }
+ ],
+ "jsonGenerator": {
+ "name": "c2pa-rs",
+ "version": "0.78.0"
+ },
+ "usedITL": false,
+ "usedTestCerts": false,
+ "_conformanceToolVersion": {
+ "commit": "d291fb884e02665d169a38928392c2710f6f0953",
+ "shortCommit": "d291fb8",
+ "date": "2026-03-19 20:11:48 -0400",
+ "branch": "HEAD",
+ "generatedAt": "2026-03-23T18:42:00.448Z"
+ }
+}
\ No newline at end of file
diff --git a/src/lib/rubrics/__fixtures__/t2i-plus.signals.json b/src/lib/rubrics/__fixtures__/t2i-plus.signals.json
new file mode 100644
index 0000000..e6a165a
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/t2i-plus.signals.json
@@ -0,0 +1,85 @@
+{
+ "rubricName": "C2PA Asset Signals Rubric (Local)",
+ "rubricVersion": "1.0.0",
+ "manifests": [
+ {
+ "assertedBy": {
+ "CN": "Google Media Processing Services",
+ "O": "Google LLC",
+ "OU": "Google System 67154"
+ },
+ "mimeType": null,
+ "localInceptions": [],
+ "localTransformations": [
+ {
+ "trait": "transformation:signal_editorialAI",
+ "reportText": "Contains Editorial GenAI Transformations",
+ "multiple": false
+ },
+ {
+ "trait": "transformation:signal_editorialNonAI",
+ "reportText": "Contains Editorial Non-GenAI Transformations",
+ "multiple": false
+ },
+ {
+ "trait": "transformation:signal_editorialPossiblyGenAI",
+ "reportText": "Contains Editorial Transformations Possibly Using GenAI",
+ "multiple": false
+ },
+ {
+ "trait": "transformation:signal_nonEditorial",
+ "reportText": "Contains Non-editorial Transformations",
+ "multiple": false
+ }
+ ],
+ "allActionsIncluded": false,
+ "ingredients": [
+ {
+ "index": 1,
+ "relationship": "parentOf"
+ }
+ ]
+ },
+ {
+ "assertedBy": {
+ "CN": "Google Media Processing Services",
+ "O": "Google LLC",
+ "OU": "Google System 60032"
+ },
+ "mimeType": null,
+ "localInceptions": [
+ {
+ "trait": "inception:signal_fullyGenAIMedia",
+ "reportText": "Contains Fully GenAI Media",
+ "multiple": false
+ }
+ ],
+ "localTransformations": [],
+ "allActionsIncluded": false,
+ "ingredients": [
+ {
+ "index": 2,
+ "relationship": "inputTo"
+ }
+ ]
+ },
+ {
+ "assertedBy": {
+ "CN": "Google Media Processing Services",
+ "O": "Google LLC",
+ "OU": "Google System 60032"
+ },
+ "mimeType": "image/jpeg",
+ "localInceptions": [
+ {
+ "trait": "inception:signal_fullyGenAIMedia",
+ "reportText": "Contains Fully GenAI Media",
+ "multiple": false
+ }
+ ],
+ "localTransformations": [],
+ "allActionsIncluded": false,
+ "ingredients": []
+ }
+ ]
+}
diff --git a/src/lib/rubrics/__fixtures__/t2i2v.conformance.json b/src/lib/rubrics/__fixtures__/t2i2v.conformance.json
new file mode 100644
index 0000000..548f09d
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/t2i2v.conformance.json
@@ -0,0 +1,71 @@
+{
+ "rubricName": "C2PA Asset Conformance 0.1 Spec 2.2 Rubric",
+ "rubricVersion": "0.1.0",
+ "true": {
+ "validation": [
+ {
+ "trait": "active_manifest_urn",
+ "reportText": "Active manifest URN uses standard 'urn:c2pa:' prefix"
+ },
+ {
+ "trait": "inception_action_position",
+ "reportText": "Inception action is correctly positioned as the first item in the first created actions assertion"
+ },
+ {
+ "trait": "ingredient_relationship_values",
+ "reportText": "All ingredient assertions have valid relationship values"
+ },
+ {
+ "trait": "ingredient_v3_no_active_manifest",
+ "reportText": "Ingredient v3 assertions without activeManifest correctly omit validationResults"
+ },
+ {
+ "trait": "no_deprecated_actions",
+ "reportText": "No deprecated actions used"
+ },
+ {
+ "trait": "no_deprecated_assertions",
+ "reportText": "No deprecated standard assertions found"
+ },
+ {
+ "trait": "no_unsupported_assertions",
+ "reportText": "All standard assertions are supported for Spec 2.2"
+ },
+ {
+ "trait": "review_ratings_datasource",
+ "reportText": "No invalid co-occurrence of reviewRatings and human entry dataSource"
+ },
+ {
+ "trait": "trusted_data_present",
+ "reportText": "Validation results are present for trust analysis"
+ },
+ {
+ "trait": "trusted_success",
+ "reportText": "Asset is trusted"
+ },
+ {
+ "trait": "valid_data_present",
+ "reportText": "Validation results are present for integrity analysis"
+ },
+ {
+ "trait": "valid_success",
+ "reportText": "No validation mismatches found"
+ },
+ {
+ "trait": "well_formed_data_present",
+ "reportText": "Validation results are present for structural analysis"
+ },
+ {
+ "trait": "well_formed_success",
+ "reportText": "No structural failures found"
+ },
+ {
+ "trait": "update_manifest_constraints",
+ "reportText": "Update manifest constraints are satisfied (or manifest is not an update manifest)"
+ }
+ ]
+ },
+ "false": {
+ "validation": []
+ }
+}
diff --git a/src/lib/rubrics/__fixtures__/t2i2v.json b/src/lib/rubrics/__fixtures__/t2i2v.json
new file mode 100644
index 0000000..35eff86
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/t2i2v.json
@@ -0,0 +1,362 @@
+{
+ "@context": {
+ "@vocab": "https://contentcredentials.org/crjson",
+ "extras": "https://contentcredentials.org/crjson/extras"
+ },
+ "manifests": [
+ {
+ "label": "urn:c2pa:386a8b21-42da-f4c2-42a6-42fa32886589",
+ "assertions": {
+ "c2pa.hash.bmff.v3": {
+ "exclusions": [
+ {
+ "xpath": "/ftyp"
+ },
+ {
+ "xpath": "/uuid",
+ "data": [
+ {
+ "offset": 8,
+ "value": [
+ 216,
+ 254,
+ 195,
+ 214,
+ 27,
+ 14,
+ 72,
+ 60,
+ 146,
+ 151,
+ 88,
+ 40,
+ 135,
+ 126,
+ 196,
+ 129
+ ]
+ }
+ ]
+ },
+ {
+ "xpath": "/mfra"
+ },
+ {
+ "xpath": "/free"
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'prANMMdIUop24eAt6kCaVjGkzJ5acJFvBcGoyXEkdKI=",
+ "name": "BMFF file hash",
+ "pad": "b64'"
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.created",
+ "description": "Created by Google Generative AI.",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia",
+ "parameters": {
+ "ingredients": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'NeLU3hO2vmRhi/O1zpAW26F/53xD7Te5IU3x+Gmsr3E=",
+ "pad": "b64'"
+ }
+ ]
+ }
+ }
+ ]
+ },
+ "c2pa.ingredient.v3": {
+ "relationship": "inputTo",
+ "validationResults": {
+ "activeManifest": {
+ "success": [
+ {
+ "code": "signingCredential.ocsp.notRevoked",
+ "url": "self#jumbf=/c2pa/urn:c2pa:6594acf4-1f15-d522-eca9-417698dcbaf4/c2pa.signature"
+ },
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:6594acf4-1f15-d522-eca9-417698dcbaf4/c2pa.signature"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:6594acf4-1f15-d522-eca9-417698dcbaf4/c2pa.signature"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:6594acf4-1f15-d522-eca9-417698dcbaf4/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:6594acf4-1f15-d522-eca9-417698dcbaf4/c2pa.signature"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:6594acf4-1f15-d522-eca9-417698dcbaf4/c2pa.signature"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:6594acf4-1f15-d522-eca9-417698dcbaf4/c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:6594acf4-1f15-d522-eca9-417698dcbaf4/c2pa.assertions/c2pa.hash.data"
+ },
+ {
+ "code": "assertion.dataHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:6594acf4-1f15-d522-eca9-417698dcbaf4/c2pa.assertions/c2pa.hash.data"
+ }
+ ],
+ "informational": [],
+ "failure": []
+ }
+ },
+ "activeManifest": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:6594acf4-1f15-d522-eca9-417698dcbaf4",
+ "hash": "b64'wRQsDipgBSIvZUOAoaaiCEOGCCTYT0UY+I7cR4/Sj4w=",
+ "pad": "b64'"
+ },
+ "claimSignature": {
+ "url": "self#jumbf=/c2pa/urn:c2pa:6594acf4-1f15-d522-eca9-417698dcbaf4/c2pa.signature",
+ "hash": "b64'MahWxo5zYfzrJ1gEGe2Bxqwbms9tmVtVOLupytsssVc=",
+ "pad": "b64'"
+ },
+ "description": "Input ingredient 0"
+ }
+ },
+ "claim.v2": {
+ "instanceID": "960aae30-8023-b6eb-f87d-4e3d8d876c35",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:386a8b21-42da-f4c2-42a6-42fa32886589/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA Core Generator Library",
+ "version": "808345081:808345081"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.ingredient.v3",
+ "hash": "b64'NeLU3hO2vmRhi/O1zpAW26F/53xD7Te5IU3x+Gmsr3E="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'afITE4TsWIBtT0Nvmq8XZn10iG6iVW2K5b6TJyITqpg="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3",
+ "hash": "b64'sFZvHZUWOEK9eR9ay1CD+FvDYRfDhUbPtwIHswBxfeQ="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "19e8483a6248fb45b185a31f672b844007ed7d",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Media Services 1P ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "OU": "Google System 98649",
+ "CN": "Google Media Processing Services"
+ },
+ "validity": {
+ "notBefore": "2025-08-15T17:37:52+00:00",
+ "notAfter": "2026-08-10T17:37:51+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2025-09-19T01:47:40+00:00",
+ "certificateInfo": {
+ "serialNumber": "a3e6ce9b0e2d6c0443cb71902c6d8f891dd17c",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Core Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google Core Time Stamping Authority T8"
+ },
+ "validity": {
+ "notBefore": "2025-09-08T13:48:53+00:00",
+ "notAfter": "2031-09-09T01:48:52+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [
+ {
+ "code": "timeStamp.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:386a8b21-42da-f4c2-42a6-42fa32886589/c2pa.signature",
+ "explanation": "timestamp message digest matched: Google Core Time Stamping Authority T8"
+ },
+ {
+ "code": "timeStamp.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:386a8b21-42da-f4c2-42a6-42fa32886589/c2pa.signature",
+ "explanation": "timestamp cert trusted: Google Core Time Stamping Authority T8"
+ },
+ {
+ "code": "signingCredential.trusted",
+ "url": "self#jumbf=/c2pa/urn:c2pa:386a8b21-42da-f4c2-42a6-42fa32886589/c2pa.signature",
+ "explanation": "signing certificate trusted, found in System trust anchors"
+ },
+ {
+ "code": "claimSignature.insideValidity",
+ "url": "self#jumbf=/c2pa/urn:c2pa:386a8b21-42da-f4c2-42a6-42fa32886589/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "claimSignature.validated",
+ "url": "self#jumbf=/c2pa/urn:c2pa:386a8b21-42da-f4c2-42a6-42fa32886589/c2pa.signature",
+ "explanation": "claim signature valid"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:386a8b21-42da-f4c2-42a6-42fa32886589/c2pa.assertions/c2pa.ingredient.v3",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.ingredient.v3"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:386a8b21-42da-f4c2-42a6-42fa32886589/c2pa.assertions/c2pa.actions.v2",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.actions.v2"
+ },
+ {
+ "code": "assertion.hashedURI.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:386a8b21-42da-f4c2-42a6-42fa32886589/c2pa.assertions/c2pa.hash.bmff.v3",
+ "explanation": "hashed uri matched: self#jumbf=c2pa.assertions/c2pa.hash.bmff.v3"
+ },
+ {
+ "code": "assertion.bmffHash.match",
+ "url": "self#jumbf=/c2pa/urn:c2pa:386a8b21-42da-f4c2-42a6-42fa32886589/c2pa.assertions/c2pa.hash.bmff.v3",
+ "explanation": "BMFF hash valid"
+ }
+ ],
+ "informational": [
+ {
+ "code": "signingCredential.ocsp.notRevoked",
+ "url": "self#jumbf=/c2pa/urn:c2pa:386a8b21-42da-f4c2-42a6-42fa32886589/c2pa.signature",
+ "explanation": "signing cert not revoked: 577753258235401352320643432677519988913335677"
+ }
+ ],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-03-31T06:44:32.148+00:00"
+ }
+ },
+ {
+ "label": "urn:c2pa:6594acf4-1f15-d522-eca9-417698dcbaf4",
+ "assertions": {
+ "c2pa.hash.data": {
+ "exclusions": [
+ {
+ "start": 760,
+ "length": 6129
+ }
+ ],
+ "alg": "sha256",
+ "hash": "b64'lMOR4Wd9FUupVgOw8wO1vxkx/2stATY0qqBH5GE4m2Y=",
+ "pad": "b64'AAAAAAAAAAAAAAAA"
+ },
+ "c2pa.actions.v2": {
+ "actions": [
+ {
+ "action": "c2pa.created",
+ "description": "Created by Google Generative AI.",
+ "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia"
+ }
+ ]
+ }
+ },
+ "claim.v2": {
+ "instanceID": "8fba80cd-afd2-d750-2ee6-49cc9a849d7d",
+ "signature": "self#jumbf=/c2pa/urn:c2pa:6594acf4-1f15-d522-eca9-417698dcbaf4/c2pa.signature",
+ "claim_generator_info": {
+ "name": "Google C2PA Core Generator Library",
+ "version": "808345081:808345081"
+ },
+ "alg": "sha256",
+ "created_assertions": [
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.actions.v2",
+ "hash": "b64'0iRpWnXaCPtJwlIc8RruokSxpMzbiXQ7NBM1qAtHaWY="
+ },
+ {
+ "url": "self#jumbf=c2pa.assertions/c2pa.hash.data",
+ "hash": "b64'fKyMCh24Y4RuOdL5X2TmDSMsd4QQ4HkXmjgor4bZipw="
+ }
+ ],
+ "gathered_assertions": []
+ },
+ "signature": {
+ "algorithm": "es256",
+ "certificateInfo": {
+ "serialNumber": "19e8483a6248fb45b185a31f672b844007ed7d",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Media Services 1P ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "OU": "Google System 98649",
+ "CN": "Google Media Processing Services"
+ },
+ "validity": {
+ "notBefore": "2025-08-15T17:37:52+00:00",
+ "notAfter": "2026-08-10T17:37:51+00:00"
+ }
+ },
+ "timeStampInfo": {
+ "timestamp": "2025-09-19T01:45:27+00:00",
+ "certificateInfo": {
+ "serialNumber": "7b519970ffd75a959d0c40d74e86f1d7702483",
+ "issuer": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google C2PA Core Time-Stamping ICA G3"
+ },
+ "subject": {
+ "C": "US",
+ "O": "Google LLC",
+ "CN": "Google Core Time Stamping Authority T11"
+ },
+ "validity": {
+ "notBefore": "2025-09-08T13:48:59+00:00",
+ "notAfter": "2031-09-09T01:48:58+00:00"
+ }
+ }
+ }
+ },
+ "validationResults": {
+ "success": [],
+ "informational": [],
+ "failure": [],
+ "specVersion": "2.3.0",
+ "validationTime": "2026-03-31T06:44:32.149+00:00"
+ }
+ }
+ ],
+ "jsonGenerator": {
+ "name": "c2pa-rs",
+ "version": "0.78.0"
+ },
+ "usedITL": false,
+ "usedTestCerts": false,
+ "_conformanceToolVersion": {
+ "commit": "d291fb884e02665d169a38928392c2710f6f0953",
+ "shortCommit": "d291fb8",
+ "date": "2026-03-19 20:11:48 -0400",
+ "branch": "HEAD",
+ "generatedAt": "2026-03-23T18:42:00.448Z"
+ }
+}
\ No newline at end of file
diff --git a/src/lib/rubrics/__fixtures__/t2i2v.signals.json b/src/lib/rubrics/__fixtures__/t2i2v.signals.json
new file mode 100644
index 0000000..9ed4b76
--- /dev/null
+++ b/src/lib/rubrics/__fixtures__/t2i2v.signals.json
@@ -0,0 +1,47 @@
+{
+ "rubricName": "C2PA Asset Signals Rubric (Local)",
+ "rubricVersion": "1.0.0",
+ "manifests": [
+ {
+ "assertedBy": {
+ "CN": "Google Media Processing Services",
+ "O": "Google LLC",
+ "OU": "Google System 98649"
+ },
+ "mimeType": null,
+ "localInceptions": [
+ {
+ "trait": "inception:signal_fullyGenAIMedia",
+ "reportText": "Contains Fully GenAI Media",
+ "multiple": false
+ }
+ ],
+ "localTransformations": [],
+ "allActionsIncluded": false,
+ "ingredients": [
+ {
+ "index": 1,
+ "relationship": "inputTo"
+ }
+ ]
+ },
+ {
+ "assertedBy": {
+ "CN": "Google Media Processing Services",
+ "O": "Google LLC",
+ "OU": "Google System 98649"
+ },
+ "mimeType": null,
+ "localInceptions": [
+ {
+ "trait": "inception:signal_fullyGenAIMedia",
+ "reportText": "Contains Fully GenAI Media",
+ "multiple": false
+ }
+ ],
+ "localTransformations": [],
+ "allActionsIncluded": false,
+ "ingredients": []
+ }
+ ]
+}
diff --git a/src/lib/rubrics/conformance.test.ts b/src/lib/rubrics/conformance.test.ts
new file mode 100644
index 0000000..1671eb0
--- /dev/null
+++ b/src/lib/rubrics/conformance.test.ts
@@ -0,0 +1,46 @@
+/**
+ * Sanity checks on the 0.1 / Spec-2.2 conformance rubric as shipped.
+ *
+ * The *parity* assertions against `capture.conformance.json` (and every other
+ * fixture triple) live in `goldens.test.ts`, which runs the same logic across
+ * all upstream fixtures. This file keeps only the structural invariants of
+ * the rubric YAML itself — so if somebody edits the rubric in a way that
+ * drops every statement, or introduces a new category we weren't expecting,
+ * we'll notice here.
+ */
+
+import { describe, expect, it } from 'vitest'
+import fs from 'node:fs'
+import path from 'node:path'
+import { parseRubricYaml } from './loader'
+
+const RUBRIC_PATH = path.resolve(
+ __dirname,
+ '../../../public/rubrics/asset-rubric-conformance0.1-spec2.2.yml',
+)
+
+describe('conformance rubric · shape invariants (0.1 spec 2.2)', () => {
+ const rubric = parseRubricYaml(
+ fs.readFileSync(RUBRIC_PATH, 'utf8'),
+ 'asset-rubric-conformance0.1-spec2.2.yml',
+ )
+
+ it('ships a non-empty list of statements', () => {
+ expect(rubric.statements.length).toBeGreaterThan(0)
+ })
+
+ it('all statements are in the `validation:` category', () => {
+ const categories = new Set(
+ rubric.statements.map((s) => (s.id.includes(':') ? s.id.split(':', 1)[0] : 'general')),
+ )
+ expect([...categories]).toEqual(['validation'])
+ })
+
+ it('every statement has a json-formula expression and reportText for "true" + "false"', () => {
+ for (const s of rubric.statements) {
+ expect(s.expression, `${s.id} missing expression`).toBeTruthy()
+ expect(s.reportText?.['true'], `${s.id} missing reportText.true`).toBeTruthy()
+ expect(s.reportText?.['false'], `${s.id} missing reportText.false`).toBeTruthy()
+ }
+ })
+})
diff --git a/src/lib/rubrics/context.ts b/src/lib/rubrics/context.ts
new file mode 100644
index 0000000..42eaff1
--- /dev/null
+++ b/src/lib/rubrics/context.ts
@@ -0,0 +1,46 @@
+/**
+ * Build the json-formula evaluation context from a crJSON report.
+ *
+ * The Python reference evaluator expects `validationResults` to live at
+ * `manifests[i].validationResults` (so expressions like
+ * `manifests[0].validationResults.failure[?...]` work). c2pa-rs sometimes
+ * emits it at the document root instead; we normalize by mirroring the
+ * root-level validation into `manifests[0]` if it isn't already present.
+ *
+ * Otherwise the context is pass-through: our crJSON already has
+ * `manifest.assertions` as a label-keyed object and `manifest['claim.v2']`
+ * as a dotted key, which is exactly what the rubric expressions assume.
+ */
+
+import type { CrJson, CrJsonManifestEntry } from '../crjson'
+
+/**
+ * Evaluation context — a normalized crJSON ready for json-formula.
+ *
+ * Typed loosely as `Record` because json-formula doesn't care
+ * about our nominal types and the rubric expressions reach into arbitrary
+ * fields on assertions / claim / signature.
+ */
+export type EvalContext = Record
+
+export function buildEvalContext(report: CrJson): EvalContext {
+ // Shallow-clone so we don't mutate the caller's report.
+ const ctx: Record = { ...report }
+
+ const rootValidation = (report as Record).validationResults
+
+ // If validationResults is at document-root, mirror it into each manifest
+ // that lacks its own per-manifest validationResults. The Python reference
+ // rubrics reference `manifests[0].validationResults`, so per-manifest is
+ // the canonical shape for evaluation.
+ if (Array.isArray(report.manifests) && rootValidation != null) {
+ ctx.manifests = report.manifests.map((m: CrJsonManifestEntry) => {
+ if (m && typeof m === 'object' && m.validationResults == null) {
+ return { ...m, validationResults: rootValidation }
+ }
+ return m
+ })
+ }
+
+ return ctx
+}
diff --git a/src/lib/rubrics/engine.test.ts b/src/lib/rubrics/engine.test.ts
new file mode 100644
index 0000000..44fda57
--- /dev/null
+++ b/src/lib/rubrics/engine.test.ts
@@ -0,0 +1,104 @@
+/**
+ * Tests for the json-formula engine wrapper.
+ *
+ * Most engine behavior is exercised end-to-end by the goldens (and by
+ * `evaluate.test.ts` / `perManifest.test.ts`). This file pins down the
+ * pieces that aren't easy to demonstrate via real rubrics — specifically
+ * the bare-keyword normalizer and named-expression invocation with $argN.
+ */
+import { describe, expect, it } from 'vitest'
+import { createEngine, normalizeExpression } from './engine'
+
+describe('normalizeExpression', () => {
+ it('rewrites bare true / false / null to function calls', () => {
+ expect(normalizeExpression('contains(arr, true)')).toBe('contains(arr, true())')
+ expect(normalizeExpression('x == false')).toBe('x == false()')
+ expect(normalizeExpression('y != null')).toBe('y != null()')
+ })
+
+ it('leaves string literals alone', () => {
+ expect(normalizeExpression('"true is here"')).toBe('"true is here"')
+ expect(normalizeExpression("'true.field'")).toBe("'true.field'")
+ expect(normalizeExpression('`true`')).toBe('`true`')
+ })
+
+ it("doesn't touch identifiers that contain a keyword", () => {
+ expect(normalizeExpression('is_true')).toBe('is_true')
+ expect(normalizeExpression('truely == falsehood')).toBe('truely == falsehood')
+ expect(normalizeExpression('null_check')).toBe('null_check')
+ })
+
+ it('leaves already-called keyword functions alone', () => {
+ expect(normalizeExpression('contains(arr, true())')).toBe('contains(arr, true())')
+ expect(normalizeExpression('false() && null()')).toBe('false() && null()')
+ })
+
+ it('handles whitespace between keyword and parens', () => {
+ expect(normalizeExpression('true ()')).toBe('true ()')
+ expect(normalizeExpression('true\n()')).toBe('true\n()')
+ })
+
+ it('handles the real upstream pattern from no_unsupported_assertions', () => {
+ const expr = 'contains(startsWith(@, $allowed), true)'
+ expect(normalizeExpression(expr)).toBe('contains(startsWith(@, $allowed), true())')
+ })
+})
+
+describe('createEngine', () => {
+ it('returns null/true/false coerced correctly even when used as bare keywords', () => {
+ const engine = createEngine({ name: 'test' })
+ expect(engine.search('null', {})).toBe(null)
+ expect(engine.search('true', {})).toBe(true)
+ expect(engine.search('false', {})).toBe(false)
+ })
+
+ it('exposes variables via $name globals', () => {
+ const engine = createEngine({
+ name: 'test',
+ variables: { $codes: ['a', 'b', 'c'] },
+ })
+ expect(engine.search('contains($codes, "b")', {})).toBe(true)
+ })
+
+ it('registers named expressions as zero-arg functions', () => {
+ const engine = createEngine({
+ name: 'test',
+ expressions: { _firstFailure: 'manifests[0].validationResults.failure[0].code' },
+ })
+ const data = {
+ manifests: [{ validationResults: { failure: [{ code: 'boom' }] } }],
+ }
+ expect(engine.search('_firstFailure()', data)).toBe('boom')
+ })
+
+ it('passes positional args to parameterised _expressions via $argN injection', () => {
+ const engine = createEngine({
+ name: 'test',
+ expressions: {
+ _hasCode: 'manifests[0].validationResults.failure[?code == $arg0].code',
+ },
+ })
+ const data = {
+ manifests: [{ validationResults: { failure: [{ code: 'boom' }, { code: 'bam' }] } }],
+ }
+ expect(engine.search('_hasCode("boom")', data)).toEqual(['boom'])
+ expect(engine.search('_hasCode("nope")', data)).toEqual([])
+ })
+
+ it('isolates $argN injection across nested calls (save/restore)', () => {
+ // Two named expressions: outer calls inner; both reference $arg0. The
+ // engine must restore outer's $arg0 after inner returns.
+ const engine = createEngine({
+ name: 'test',
+ expressions: {
+ _inner: '$arg0',
+ _outer: '[$arg0, _inner("inner-val"), $arg0]',
+ },
+ })
+ expect(engine.search('_outer("outer-val")', {})).toEqual([
+ 'outer-val',
+ 'inner-val',
+ 'outer-val',
+ ])
+ })
+})
diff --git a/src/lib/rubrics/engine.ts b/src/lib/rubrics/engine.ts
new file mode 100644
index 0000000..627ab0d
--- /dev/null
+++ b/src/lib/rubrics/engine.ts
@@ -0,0 +1,254 @@
+/**
+ * json-formula engine wrapper.
+ *
+ * Mirrors the Python reference evaluator at
+ * `../../c2pa/conformance/asset-rubrics/c2pa_conformance_rubric_evaluator.py::create_json_formula_engine`
+ * so the same rubric YAMLs evaluate identically in the browser.
+ *
+ * Rubrics now carry two extra metadata blocks alongside `rubric_metadata`:
+ *
+ * - `variables:` → plain `$name: value` globals, passed as the `globals`
+ * argument on every `search()` call.
+ *
+ * - `expressions:` → `_name: ""` named expressions, registered as
+ * custom functions. They can reference `$argN` positional parameters,
+ * which we inject into the interpreter's `globals` at call time and
+ * restore afterwards so nested calls don't leak state.
+ *
+ * Keep this file free of evaluator-specific logic (pass/fail, coercion,
+ * reportText). Those stay in `evaluate.ts` / `perManifest.ts`.
+ */
+import JsonFormula, {
+ dataTypes,
+ type CustomFunctionEntry,
+ type Interpreter,
+ type JsonFormulaAst,
+} from '@adobe/json-formula'
+import type { RubricMetadata } from './types'
+
+/** Thin façade exposing just the methods the evaluators call. */
+export interface RubricEngine {
+ /** Evaluate an expression string against `data`. */
+ search(expression: string, data: unknown): unknown
+ /** The resolved `$name` globals — pulled from rubric metadata. */
+ readonly variables: Record
+}
+
+/** Build an engine configured for one rubric's variables + named expressions. */
+export function createEngine(metadata: RubricMetadata): RubricEngine {
+ const variables: Record = { ...(metadata.variables ?? {}) }
+ const expressions: Record = { ...(metadata.expressions ?? {}) }
+
+ // Determine the widest `$argN` fingerprint across all named expressions so
+ // the parser will accept those identifiers when compiling any of them.
+ const maxArity = Object.values(expressions).reduce(
+ (acc, expr) => Math.max(acc, argCount(expr)),
+ 0,
+ )
+ const argNames = Array.from({ length: maxArity }, (_, i) => `$arg${i}`)
+
+ // Pre-compile each named expression once. Parsing happens now; execution
+ // happens every time the expression is invoked (possibly many times per
+ // statement via nested `_name()` calls).
+ const compileHelper = new JsonFormula({}, null, [])
+ const allowedGlobals = [
+ ...Object.keys(variables),
+ ...Object.keys(expressions),
+ ...argNames,
+ ]
+
+ const compiled = new Map()
+ for (const [name, exprStr] of Object.entries(expressions)) {
+ const arity = argCount(exprStr)
+ try {
+ compiled.set(name, {
+ ast: compileHelper.compile(normalizeExpression(exprStr), allowedGlobals),
+ arity,
+ })
+ } catch (err) {
+ compiled.set(name, {
+ ast: null,
+ arity,
+ error: err instanceof Error ? err.message : String(err),
+ })
+ }
+ }
+
+ const customFunctions: Record = {}
+ for (const [name, entry] of compiled) {
+ customFunctions[name] = {
+ _signature: makeSignature(entry.arity),
+ _func: makeExpressionFn(name, entry),
+ }
+ }
+
+ const engine = new JsonFormula(customFunctions, null, [])
+
+ return {
+ variables,
+ search(expression: string, data: unknown): unknown {
+ return engine.search(normalizeExpression(expression), data, variables)
+ },
+ }
+}
+
+/**
+ * Rewrite bare `true` / `false` / `null` keywords to their zero-arg function
+ * form (`true()`, etc.).
+ *
+ * Why: `@adobe/json-formula` 2.0 registers `true`/`false`/`null` as zero-arg
+ * functions but does NOT auto-invoke them when written without parens — bare
+ * `true` is parsed as a field access (current value's `true` property),
+ * which yields `null` and breaks `contains([...], true)`. The reference
+ * Python `json-formula` package tolerates the bare form, so upstream rubrics
+ * are written that way (e.g. the `no_unsupported_assertions` rule:
+ * `contains(startsWith(@, $allowed), true)`). Normalizing here keeps the
+ * pre-built YAMLs unmodified relative to upstream while still evaluating
+ * correctly in the browser.
+ *
+ * The walk is string-aware: it skips over `"..."` (string literals),
+ * `'...'` (quoted identifiers), and `` `...` `` (JSON literals) so a
+ * keyword inside a string is never rewritten. It also leaves identifiers
+ * like `is_true` or `truely` alone (word-boundary check), and skips any
+ * keyword already followed by `(`.
+ */
+export function normalizeExpression(expr: string): string {
+ if (typeof expr !== 'string' || expr.length === 0) return expr
+ const KEYWORDS = new Set(['true', 'false', 'null'])
+ // Characters that count as "word" for the purpose of identifier detection.
+ const isWordChar = (ch: string | undefined) =>
+ ch != null && /[A-Za-z0-9_$]/.test(ch)
+
+ let out = ''
+ let i = 0
+ while (i < expr.length) {
+ const ch = expr[i]
+
+ // Skip string-like spans untouched: " ' and `.
+ if (ch === '"' || ch === "'" || ch === '`') {
+ const quote = ch
+ out += ch
+ i += 1
+ while (i < expr.length && expr[i] !== quote) {
+ if (expr[i] === '\\' && i + 1 < expr.length) {
+ out += expr[i] + expr[i + 1]
+ i += 2
+ } else {
+ out += expr[i]
+ i += 1
+ }
+ }
+ if (i < expr.length) {
+ out += expr[i] // closing quote
+ i += 1
+ }
+ continue
+ }
+
+ // Try to match one of the bare keywords at this position. They must:
+ // - not be preceded by a word char (so we don't touch `is_true`),
+ // - not be followed by a word char (so we don't touch `truely`),
+ // - not be already followed by `(` (so we don't touch `true()`).
+ const prev = i > 0 ? expr[i - 1] : undefined
+ if (!isWordChar(prev)) {
+ let matched: string | undefined
+ for (const kw of KEYWORDS) {
+ if (
+ expr.startsWith(kw, i) &&
+ !isWordChar(expr[i + kw.length])
+ ) {
+ matched = kw
+ break
+ }
+ }
+ if (matched) {
+ // Look past whitespace for an opening `(` — if present, it's already
+ // a function call and we leave it alone.
+ let j = i + matched.length
+ while (j < expr.length && /\s/.test(expr[j])) j += 1
+ if (expr[j] !== '(') {
+ out += `${matched}()`
+ i += matched.length
+ continue
+ }
+ }
+ }
+
+ out += ch
+ i += 1
+ }
+ return out
+}
+
+/** Count the highest `$argN` index referenced in an expression, +1. Zero if none. */
+function argCount(expr: string | undefined): number {
+ if (!expr) return 0
+ const re = /\$arg(\d+)/g
+ let max = -1
+ for (const m of expr.matchAll(re)) {
+ const n = Number(m[1])
+ if (Number.isFinite(n) && n > max) max = n
+ }
+ return max + 1
+}
+
+/** Build an `_signature` list accepting exactly `arity` positional args (any type). */
+function makeSignature(arity: number) {
+ if (arity === 0) return []
+ return Array.from({ length: arity }, () => ({ types: [dataTypes.TYPE_ANY] }))
+}
+
+/**
+ * Build the `_func` for a named expression. Zero-arity forms just re-evaluate
+ * the compiled AST against the caller's data. Parameterised forms inject the
+ * caller-provided values as `$arg0`, `$arg1`, ... into the interpreter's
+ * `globals`, evaluate, then restore the prior values — matching the Python
+ * reference's save/restore dance exactly.
+ */
+function makeExpressionFn(
+ name: string,
+ entry: { ast: JsonFormulaAst | null; arity: number; error?: string },
+): CustomFunctionEntry['_func'] {
+ if (entry.arity === 0) {
+ return (_args, data, interpreter) => {
+ if (entry.ast == null) {
+ throw new Error(`Expression '${name}' failed to compile: ${entry.error ?? 'unknown error'}`)
+ }
+ return interpreter.search(entry.ast, data)
+ }
+ }
+ return (args, data, interpreter) => {
+ if (entry.ast == null) {
+ throw new Error(`Expression '${name}' failed to compile: ${entry.error ?? 'unknown error'}`)
+ }
+ return withInjectedGlobals(
+ interpreter,
+ Object.fromEntries(args.map((v, i) => [`$arg${i}`, v])),
+ () => interpreter.search(entry.ast as JsonFormulaAst, data),
+ )
+ }
+}
+
+/** Run `fn` with extra entries merged into `interpreter.globals`, restoring on exit. */
+function withInjectedGlobals(
+ interpreter: Interpreter,
+ extra: Record,
+ fn: () => T,
+): T {
+ const prior = new Map()
+ for (const k of Object.keys(extra)) {
+ prior.set(k, {
+ had: Object.prototype.hasOwnProperty.call(interpreter.globals, k),
+ value: interpreter.globals[k],
+ })
+ interpreter.globals[k] = extra[k]
+ }
+ try {
+ return fn()
+ } finally {
+ for (const [k, snap] of prior) {
+ if (snap.had) interpreter.globals[k] = snap.value
+ else delete interpreter.globals[k]
+ }
+ }
+}
diff --git a/src/lib/rubrics/evaluate.test.ts b/src/lib/rubrics/evaluate.test.ts
new file mode 100644
index 0000000..a57ccda
--- /dev/null
+++ b/src/lib/rubrics/evaluate.test.ts
@@ -0,0 +1,147 @@
+/**
+ * Golden test: verify the TS evaluator produces the same pass/fail outcomes
+ * as the Python reference evaluator on a known-good input.
+ *
+ * Fixtures are copied from `/Users/andyp/Desktop/Projects/c2pa/conformance/asset-rubrics/test/`:
+ * - capture.json — input crJSON for a clean captured-media asset
+ * - capture.conformance.json — expected output from the full conformance rubric
+ *
+ * The integrity rubric's six statements all appear under "true" in
+ * capture.conformance.json, so we assert that our evaluator reports all six
+ * as passed.
+ */
+
+import { describe, expect, it } from 'vitest'
+import fs from 'node:fs'
+import path from 'node:path'
+import type { CrJson } from '../crjson'
+import { parseRubricYaml } from './loader'
+import { evaluateRubric } from './evaluate'
+
+const FIXTURE_DIR = path.resolve(__dirname, '__fixtures__')
+const RUBRIC_PATH = path.resolve(__dirname, '../../../public/rubrics/asset-rubric-integrity.yml')
+
+function loadFixture(filename: string): T {
+ const raw = fs.readFileSync(path.join(FIXTURE_DIR, filename), 'utf8')
+ return JSON.parse(raw) as T
+}
+
+describe('integrity rubric · golden parity with Python reference', () => {
+ const input = loadFixture('capture.json')
+ const rubric = parseRubricYaml(fs.readFileSync(RUBRIC_PATH, 'utf8'), 'asset-rubric-integrity.yml')
+
+ it('loads all 6 integrity statements', () => {
+ expect(rubric.statements.map((s) => s.id)).toEqual([
+ 'validation:well_formed_data_present',
+ 'validation:well_formed_success',
+ 'validation:valid_data_present',
+ 'validation:valid_success',
+ 'validation:trusted_data_present',
+ 'validation:trusted_success',
+ ])
+ })
+
+ it('all statements pass on capture.json (clean captured-media asset)', () => {
+ const result = evaluateRubric(rubric, input, { rubricId: 'asset-integrity' })
+
+ const failed = result.statements.filter((s) => s.passed !== true)
+ // If this fails, print which statements regressed and the raw value so
+ // we can see exactly where the TS port diverges from the Python reference.
+ if (failed.length > 0) {
+ console.error(
+ 'Regressions:\n' +
+ failed
+ .map(
+ (s) =>
+ ` - ${s.id}: passed=${s.passed} raw=${JSON.stringify(s.rawValue)} error=${s.error ?? ''}`,
+ )
+ .join('\n'),
+ )
+ }
+ expect(failed).toEqual([])
+ expect(result.overallPassed).toBe(true)
+ })
+
+ it('selects the "true" reportText for passing statements', () => {
+ const result = evaluateRubric(rubric, input, { rubricId: 'asset-integrity' })
+ const wellFormed = result.statements.find((s) => s.id === 'validation:well_formed_success')
+ expect(wellFormed?.message).toBe('No structural failures found')
+ })
+
+ it('sets category from the id prefix', () => {
+ const result = evaluateRubric(rubric, input, { rubricId: 'asset-integrity' })
+ expect(result.statements.every((s) => s.category === 'validation')).toBe(true)
+ })
+})
+
+describe('coercion rules', () => {
+ it('failIfMatched inverts a non-empty list to a failure with matches', () => {
+ const rubric = parseRubricYaml(
+ [
+ 'rubric_metadata:',
+ ' name: test',
+ ' version: 1.0.0',
+ '---',
+ '- id: test:absence',
+ ' failIfMatched: true',
+ ' expression: |-',
+ ' failures[?contains(["boom"], code)].code',
+ ' reportText:',
+ " 'true':",
+ ' en: No boom',
+ " 'false':",
+ " en: 'Boom found: {{matches}}'",
+ ].join('\n'),
+ 'inline',
+ )
+ const ctx = { failures: [{ code: 'boom' }, { code: 'other' }] } as unknown as CrJson
+ const result = evaluateRubric(rubric, ctx, { rubricId: 'test' })
+ expect(result.statements[0].passed).toBe(false)
+ expect(result.statements[0].message).toBe('Boom found: boom')
+ })
+
+ it('failIfMatched on an empty list passes', () => {
+ const rubric = parseRubricYaml(
+ [
+ 'rubric_metadata:',
+ ' name: test',
+ '---',
+ '- id: test:absence',
+ ' failIfMatched: true',
+ ' expression: |-',
+ ' failures[?code == "boom"].code',
+ ' reportText:',
+ " 'true':",
+ ' en: No boom',
+ " 'false':",
+ " en: 'Boom: {{matches}}'",
+ ].join('\n'),
+ 'inline',
+ )
+ const ctx = { failures: [] } as unknown as CrJson
+ const result = evaluateRubric(rubric, ctx, { rubricId: 'test' })
+ expect(result.statements[0].passed).toBe(true)
+ expect(result.statements[0].message).toBe('No boom')
+ })
+
+ it('records an error when the expression is invalid', () => {
+ const rubric = parseRubricYaml(
+ [
+ 'rubric_metadata:',
+ ' name: test',
+ '---',
+ '- id: test:bad',
+ ' expression: |-',
+ ' ))) not valid jmespath',
+ ' reportText:',
+ " 'true': { en: ok }",
+ " 'false': { en: bad }",
+ ].join('\n'),
+ 'inline',
+ )
+ const ctx = {} as CrJson
+ const result = evaluateRubric(rubric, ctx, { rubricId: 'test' })
+ expect(result.statements[0].passed).toBe(null)
+ expect(result.statements[0].error).toBeTruthy()
+ })
+})
diff --git a/src/lib/rubrics/evaluate.ts b/src/lib/rubrics/evaluate.ts
new file mode 100644
index 0000000..07b0923
--- /dev/null
+++ b/src/lib/rubrics/evaluate.ts
@@ -0,0 +1,164 @@
+/**
+ * Rubric evaluator — runs one or more statements against a crJSON context.
+ *
+ * Mirrors the coercion and reportText rules from the Python reference at
+ * `c2pa_conformance_rubric_evaluator.py`. Key rules:
+ *
+ * - json-formula result coercion (normal case):
+ * list → passed = list.length > 0
+ * bool → passed = val
+ * number → passed = val > 0
+ * null → passed = false
+ * other → passed = true
+ *
+ * - failIfMatched:
+ * list & non-empty → passed = false, matches = list
+ * otherwise → passed = true
+ *
+ * - reportText[passed ? 'true' : 'false'][locale] is selected; falls back
+ * to reportText['default'][locale]. `{{matches}}` is replaced with the
+ * comma-joined matches if they are strings.
+ *
+ * - Any thrown error produces passed = null and an `error` field.
+ */
+
+import type { CrJson } from '../crjson'
+import { buildEvalContext } from './context'
+import { createEngine, type RubricEngine } from './engine'
+import type { Rubric, RubricResult, RubricStatement, StatementResult } from './types'
+
+const DEFAULT_LOCALE = 'en'
+
+export function evaluateRubric(
+ rubric: Rubric,
+ report: CrJson,
+ options: { rubricId: string; locale?: string } = { rubricId: 'unknown' },
+): RubricResult {
+ const context = buildEvalContext(report)
+ const locale = options.locale ?? rubric.metadata.language ?? DEFAULT_LOCALE
+
+ // One engine per evaluation — it closes over the rubric's variables and
+ // named expressions. Cheap enough that per-call is fine; statements reuse it.
+ const engine = createEngine(rubric.metadata)
+
+ const statements = rubric.statements.map((s) => evaluateStatement(s, context, locale, engine))
+
+ const overallPassed = statements.every((s) => s.passed === true)
+
+ return {
+ rubricId: options.rubricId,
+ rubricName: rubric.metadata.name,
+ rubricVersion: rubric.metadata.version,
+ overallPassed,
+ statements,
+ evaluatedAt: new Date(),
+ }
+}
+
+export function evaluateStatement(
+ stmt: RubricStatement,
+ context: unknown,
+ locale: string = DEFAULT_LOCALE,
+ engine?: RubricEngine,
+): StatementResult {
+ const category = stmt.id.includes(':') ? stmt.id.split(':', 1)[0] : 'general'
+ // Allow ad-hoc single-statement evaluation without a rubric: fall back to a
+ // barebones engine that has no variables or named expressions. Unit tests
+ // and older callers rely on this.
+ const evalEngine = engine ?? createEngine({ name: 'ad-hoc' })
+
+ let rawValue: unknown = undefined
+ let error: string | undefined
+
+ try {
+ rawValue = evalEngine.search(stmt.expression.trim(), context)
+ } catch (e) {
+ error = e instanceof Error ? e.message : String(e)
+ return {
+ id: stmt.id,
+ category,
+ description: stmt.description,
+ passed: null,
+ rawValue: null,
+ error,
+ message: `Error: ${error}`,
+ }
+ }
+
+ const { passed, matches } = coerce(rawValue, stmt.failIfMatched === true)
+ const message = pickReportText(stmt, passed, locale, matches)
+
+ return {
+ id: stmt.id,
+ category,
+ description: stmt.description,
+ passed,
+ rawValue,
+ message,
+ }
+}
+
+/**
+ * Coerce a raw JMESPath result to a boolean outcome, following the Python
+ * reference rules. Also returns `matches` when the raw value is a list that
+ * carries match information (used for `{{matches}}` substitution).
+ */
+function coerce(val: unknown, failIfMatched: boolean): { passed: boolean; matches?: unknown[] } {
+ if (failIfMatched) {
+ if (Array.isArray(val) && val.length > 0) {
+ return { passed: false, matches: val }
+ }
+ return { passed: true }
+ }
+
+ if (Array.isArray(val)) {
+ // Non-failIfMatched list: truthy iff non-empty. If entries look like
+ // match records (have `label` or `signature`), expose them as matches.
+ if (val.length > 0) {
+ const first = val[0]
+ if (first && typeof first === 'object' && !Array.isArray(first) && ('label' in first || 'signature' in first)) {
+ return { passed: true, matches: val }
+ }
+ return { passed: true }
+ }
+ return { passed: false }
+ }
+
+ if (typeof val === 'boolean') return { passed: val }
+ if (typeof val === 'number') return { passed: val > 0 }
+ if (val == null) return { passed: false }
+ return { passed: true }
+}
+
+function pickReportText(
+ stmt: RubricStatement,
+ passed: boolean,
+ locale: string,
+ matches: unknown[] | undefined,
+): string {
+ const dict = stmt.reportText
+ if (!dict) return ''
+
+ const key = passed ? 'true' : 'false'
+ const chosen = dict[key] ?? dict.default
+ if (!chosen) return ''
+
+ // `chosen` may be either a locale-keyed object or a bare string in old rubrics.
+ let text: string
+ if (typeof chosen === 'string') {
+ text = chosen
+ } else {
+ text = chosen[locale] ?? chosen[DEFAULT_LOCALE] ?? ''
+ }
+
+ // Substitute `{{matches}}` if present and matches are strings.
+ if (text.includes('{{matches}}')) {
+ if (Array.isArray(matches) && matches.every((m) => typeof m === 'string')) {
+ text = text.replace('{{matches}}', (matches as string[]).join(', '))
+ } else {
+ text = text.replace('{{matches}}', '')
+ }
+ }
+
+ return text
+}
diff --git a/src/lib/rubrics/goldens.test.ts b/src/lib/rubrics/goldens.test.ts
new file mode 100644
index 0000000..ec9dc0d
--- /dev/null
+++ b/src/lib/rubrics/goldens.test.ts
@@ -0,0 +1,201 @@
+/**
+ * Parameterized golden-parity test: for every fixture triple copied from
+ * upstream (`.json` + `.conformance.json` + `.signals.json`),
+ * verify that our TS evaluators produce results that agree with the Python
+ * reference on every trait the fixture covers.
+ *
+ * Fixtures are stored under `__fixtures__/` and were generated by
+ * `c2pa_conformance_rubric_evaluator.py` (0.1 / Spec-2.2 rubric) and
+ * `c2pa_signals_rubric_evaluator.py` (signals-local rubric) respectively.
+ *
+ * Assertion shape notes:
+ * - Conformance: subset-match. Every trait in the fixture's
+ * `true` / `false` buckets must resolve to the same outcome and
+ * reportText on our side. Rubric statements added after the fixture
+ * was generated are ignored (forward-compatible) — that's fine
+ * because the upstream rubric has grown past its fixtures (see e.g.
+ * `thumbnail_location` which post-dates every fixture).
+ * - Signals: exact-match, field by field, in fixture order.
+ */
+
+import { describe, expect, it } from 'vitest'
+import fs from 'node:fs'
+import path from 'node:path'
+import type { CrJson } from '../crjson'
+import { parseRubricYaml } from './loader'
+import { evaluateRubric } from './evaluate'
+import { evaluatePerManifest } from './perManifest'
+import type { RubricResult } from './types'
+
+const FIXTURE_DIR = path.resolve(__dirname, '__fixtures__')
+const CONFORMANCE_RUBRIC_PATH = path.resolve(
+ __dirname,
+ '../../../public/rubrics/asset-rubric-conformance0.1-spec2.2.yml',
+)
+const SIGNALS_RUBRIC_PATH = path.resolve(
+ __dirname,
+ '../../../public/rubrics/asset-rubric-signals-local.yml',
+)
+
+// Parse the rubrics once so we're not paying YAML parse cost per fixture.
+const conformanceRubric = parseRubricYaml(
+ fs.readFileSync(CONFORMANCE_RUBRIC_PATH, 'utf8'),
+ 'asset-rubric-conformance0.1-spec2.2.yml',
+)
+const signalsRubric = parseRubricYaml(
+ fs.readFileSync(SIGNALS_RUBRIC_PATH, 'utf8'),
+ 'asset-rubric-signals-local.yml',
+)
+
+// Discover fixtures by looking for `.json` files that also have
+// companion `.conformance.json` and `.signals.json` siblings. This keeps
+// the test list self-maintaining when new fixtures land in the directory.
+function discoverFixtureNames(): string[] {
+ const all = fs.readdirSync(FIXTURE_DIR)
+ const baseInputs = all.filter(
+ (f) => f.endsWith('.json') && !f.endsWith('.conformance.json') && !f.endsWith('.signals.json'),
+ )
+ return baseInputs
+ .map((f) => f.replace(/\.json$/, ''))
+ .filter(
+ (name) =>
+ all.includes(`${name}.conformance.json`) && all.includes(`${name}.signals.json`),
+ )
+ .sort()
+}
+
+const FIXTURE_NAMES = discoverFixtureNames()
+
+// ── Conformance fixture parity ────────────────────────────────────────
+
+interface ConformanceFixture {
+ rubricName: string
+ rubricVersion: string
+ true: Record>
+ false: Record>
+}
+
+/** trait → { outcome, reportText } lookup, flattened across all categories. */
+function traitIndex(
+ result: RubricResult,
+): Map {
+ const out = new Map()
+ for (const s of result.statements) {
+ const trait = s.id.includes(':') ? s.id.split(':').slice(1).join(':') : s.id
+ const outcome: 'true' | 'false' | 'error' =
+ s.passed === true ? 'true' : s.passed === false ? 'false' : 'error'
+ out.set(`${s.category}:${trait}`, { outcome, reportText: s.message })
+ }
+ return out
+}
+
+describe('conformance rubric · parameterized golden parity (0.1 spec 2.2)', () => {
+ it(`discovered at least the original capture fixture`, () => {
+ // Cheap sanity check so a broken discover doesn't silently skip all tests.
+ expect(FIXTURE_NAMES).toContain('capture')
+ expect(FIXTURE_NAMES.length).toBeGreaterThanOrEqual(15)
+ })
+
+ for (const name of FIXTURE_NAMES) {
+ it(`${name} · every fixture trait matches our evaluator`, () => {
+ const input = JSON.parse(
+ fs.readFileSync(path.join(FIXTURE_DIR, `${name}.json`), 'utf8'),
+ ) as CrJson
+ const expected = JSON.parse(
+ fs.readFileSync(path.join(FIXTURE_DIR, `${name}.conformance.json`), 'utf8'),
+ ) as ConformanceFixture
+
+ const result = evaluateRubric(conformanceRubric, input, {
+ rubricId: 'asset-conformance-0.1-spec2.2',
+ })
+ expect(result.rubricName).toBe(expected.rubricName)
+ expect(result.rubricVersion).toBe(expected.rubricVersion)
+
+ const index = traitIndex(result)
+
+ const mismatches: string[] = []
+ const check = (
+ wantOutcome: 'true' | 'false',
+ buckets: ConformanceFixture['true'],
+ ) => {
+ for (const [category, traits] of Object.entries(buckets)) {
+ for (const { trait, reportText } of traits) {
+ const got = index.get(`${category}:${trait}`)
+ if (!got) {
+ mismatches.push(` missing: ${category}:${trait}`)
+ continue
+ }
+ if (got.outcome !== wantOutcome) {
+ mismatches.push(
+ ` ${category}:${trait} → outcome expected=${wantOutcome} got=${got.outcome}`,
+ )
+ }
+ if (got.reportText !== reportText) {
+ mismatches.push(
+ ` ${category}:${trait} → reportText diverged\n want: ${reportText}\n got: ${got.reportText}`,
+ )
+ }
+ }
+ }
+ }
+ check('true', expected.true)
+ check('false', expected.false)
+
+ expect(mismatches, `parity diffs for ${name}:\n${mismatches.join('\n')}`).toEqual([])
+ })
+ }
+})
+
+// ── Signals fixture parity ────────────────────────────────────────────
+
+interface SignalsFixture {
+ rubricName: string
+ rubricVersion: string
+ manifests: Array<{
+ assertedBy: { CN: string; O: string; OU?: string }
+ mimeType: string | null
+ localInceptions: Array<{ trait: string; reportText: string; multiple: boolean }>
+ localTransformations: Array<{ trait: string; reportText: string; multiple: boolean }>
+ allActionsIncluded: boolean
+ ingredients: Array<{ index: number; relationship?: string }>
+ }>
+}
+
+describe('signals rubric · parameterized golden parity (local)', () => {
+ for (const name of FIXTURE_NAMES) {
+ it(`${name} · per-manifest signals match the reference exactly`, () => {
+ const input = JSON.parse(
+ fs.readFileSync(path.join(FIXTURE_DIR, `${name}.json`), 'utf8'),
+ ) as CrJson
+ const expected = JSON.parse(
+ fs.readFileSync(path.join(FIXTURE_DIR, `${name}.signals.json`), 'utf8'),
+ ) as SignalsFixture
+
+ const result = evaluatePerManifest(signalsRubric, input, {
+ rubricId: 'asset-signals-local',
+ })
+
+ expect(result.rubricName).toBe(expected.rubricName)
+ expect(result.rubricVersion).toBe(expected.rubricVersion)
+ expect(result.manifests).toHaveLength(expected.manifests.length)
+
+ for (let i = 0; i < expected.manifests.length; i++) {
+ const want = expected.manifests[i]
+ const got = result.manifests[i]
+ expect(got.assertedBy, `${name} manifest[${i}] assertedBy`).toEqual(want.assertedBy)
+ expect(got.mimeType, `${name} manifest[${i}] mimeType`).toEqual(want.mimeType)
+ expect(got.allActionsIncluded, `${name} manifest[${i}] allActionsIncluded`).toEqual(
+ want.allActionsIncluded,
+ )
+ expect(got.localInceptions, `${name} manifest[${i}] localInceptions`).toEqual(
+ want.localInceptions,
+ )
+ expect(
+ got.localTransformations,
+ `${name} manifest[${i}] localTransformations`,
+ ).toEqual(want.localTransformations)
+ expect(got.ingredients, `${name} manifest[${i}] ingredients`).toEqual(want.ingredients)
+ }
+ })
+ }
+})
diff --git a/src/lib/rubrics/json-formula.d.ts b/src/lib/rubrics/json-formula.d.ts
new file mode 100644
index 0000000..aa2554e
--- /dev/null
+++ b/src/lib/rubrics/json-formula.d.ts
@@ -0,0 +1,92 @@
+/**
+ * Ambient types for `@adobe/json-formula` 2.x.
+ *
+ * The published package has no `.d.ts` files — this shim covers the shape we
+ * actually consume in `engine.ts`. It is intentionally narrow.
+ */
+declare module '@adobe/json-formula' {
+ /** Opaque AST returned by `compile()`. Only valid input for `run()`. */
+ export type JsonFormulaAst = unknown
+
+ /** The interpreter instance passed as the 3rd arg to custom `_func`s. */
+ export interface Interpreter {
+ /** Mutable map of `$name → value`. Used to inject `$argN` at call time. */
+ globals: Record
+ /** Evaluate a compiled AST against a data value. */
+ search(ast: JsonFormulaAst, data: unknown): unknown
+ }
+
+ /** A parameter slot's type constraints — we pass `[]` or a list of `TYPE_ANY`. */
+ export interface FunctionSignatureSlot {
+ types: number[]
+ optional?: boolean
+ variadic?: boolean
+ }
+
+ export interface CustomFunctionEntry {
+ /** Called with (resolvedArgs, data, interpreter). */
+ _func: (
+ args: unknown[],
+ data: unknown,
+ interpreter: Interpreter,
+ ) => unknown
+ _signature: FunctionSignatureSlot[]
+ }
+
+ export default class JsonFormula {
+ constructor(
+ customFunctions?: Record,
+ stringToNumber?: ((s: string) => number) | null,
+ debug?: unknown[],
+ )
+
+ /** Compile + run in one shot. */
+ search(
+ expression: string,
+ json: unknown,
+ globals?: Record,
+ language?: string,
+ ): unknown
+
+ /** Parse an expression once; reuse the AST with `run()`. */
+ compile(
+ expression: string,
+ allowedGlobalNames?: string[],
+ ): JsonFormulaAst
+
+ /** Evaluate a previously compiled AST. */
+ run(
+ ast: JsonFormulaAst,
+ json: unknown,
+ language?: string,
+ globals?: Record,
+ ): unknown
+ }
+
+ /** Enum of type constants used in `_signature` slots. `TYPE_ANY` is 1. */
+ export const dataTypes: {
+ TYPE_NUMBER: 0
+ TYPE_ANY: 1
+ TYPE_STRING: 2
+ TYPE_ARRAY: 3
+ TYPE_OBJECT: 4
+ TYPE_BOOLEAN: 5
+ TYPE_EXPREF: 6
+ TYPE_NULL: 7
+ TYPE_ARRAY_NUMBER: 8
+ TYPE_ARRAY_STRING: 9
+ TYPE_ARRAY_ARRAY: 10
+ TYPE_EMPTY_ARRAY: 11
+ }
+
+ /** One-shot convenience — compiles + runs. */
+ export function jsonFormula(
+ json: unknown,
+ globals: Record,
+ expression: string,
+ customFunctions?: Record,
+ stringToNumber?: ((s: string) => number) | null,
+ debug?: unknown[],
+ language?: string,
+ ): unknown
+}
diff --git a/src/lib/rubrics/loader.ts b/src/lib/rubrics/loader.ts
new file mode 100644
index 0000000..44fb468
--- /dev/null
+++ b/src/lib/rubrics/loader.ts
@@ -0,0 +1,180 @@
+/**
+ * Rubric loader — fetches rubric manifests and multi-document YAML files.
+ *
+ * Rubric YAML shape (matches the Python reference):
+ *
+ * rubric_metadata:
+ * name: ...
+ * version: ...
+ * language: en
+ * variables: # optional — shared $globals
+ * $well_formed_error_codes: [...]
+ * expressions: # optional — named reusable expressions
+ * _validationResults: |-
+ * (manifests[0].validationResults || {...})
+ * ---
+ * - id: ...
+ * expression: ...
+ * reportText: { 'true': { en: ... }, 'false': { en: ... } }
+ * - id: ...
+ * ...
+ *
+ * The first document is the metadata (plus `variables` / `expressions` at the
+ * same top level). The second (and any subsequent) document is a list of
+ * statements; statements from all later docs are concatenated.
+ */
+
+import { parseAllDocuments } from 'yaml'
+import type { Rubric, RubricIndexEntry, RubricMetadata, RubricStatement } from './types'
+
+// All rubric assets live under this base URL (honors Vite's BASE_URL for GH Pages).
+const RUBRICS_BASE = `${import.meta.env.BASE_URL}rubrics/`
+
+/** Fetch the index of available rubrics. */
+export async function loadRubricIndex(): Promise {
+ const url = `${RUBRICS_BASE}index.json`
+ const res = await fetch(url)
+ if (!res.ok) {
+ throw new Error(`Failed to load rubric index (${res.status}): ${url}`)
+ }
+ const json = (await res.json()) as { rubrics?: RubricIndexEntry[] }
+ return json.rubrics ?? []
+}
+
+/** Fetch and parse a single rubric YAML file. */
+export async function loadRubric(filename: string): Promise {
+ const url = `${RUBRICS_BASE}${filename}`
+ const res = await fetch(url)
+ if (!res.ok) {
+ throw new Error(`Failed to load rubric (${res.status}): ${url}`)
+ }
+ const yamlText = await res.text()
+ return parseRubricYaml(yamlText, filename)
+}
+
+/**
+ * Parse the multi-document YAML into a Rubric. Exported for use in tests.
+ * Accepts a single YAML string (possibly containing multiple `---` docs).
+ */
+export function parseRubricYaml(yamlText: string, filenameForError = ''): Rubric {
+ const docs = parseAllDocuments(yamlText)
+
+ if (docs.length === 0) {
+ throw new Error(`Empty rubric file: ${filenameForError}`)
+ }
+
+ // Doc 0 — metadata. Accepts either { rubric_metadata: {...} } or just {...}.
+ const firstDoc = docs[0].toJSON()
+ const metadata = extractMetadata(firstDoc, filenameForError)
+
+ // Docs 1..N — statements. Each doc is expected to be a list.
+ const statements: RubricStatement[] = []
+ for (let i = 1; i < docs.length; i++) {
+ const raw = docs[i].toJSON()
+ if (raw == null) continue // Empty trailing document — skip.
+ if (!Array.isArray(raw)) {
+ throw new Error(
+ `Rubric ${filenameForError} document ${i} is not a list of statements`,
+ )
+ }
+ for (const item of raw) {
+ const stmt = normalizeStatement(item, filenameForError)
+ if (stmt) statements.push(stmt)
+ }
+ }
+
+ // Edge case: some rubrics put everything (metadata + statements) in a single
+ // document as { rubric_metadata, statements: [...] } — support that too.
+ if (statements.length === 0 && firstDoc && typeof firstDoc === 'object' && 'statements' in firstDoc) {
+ const inlineList = (firstDoc as { statements?: unknown }).statements
+ if (Array.isArray(inlineList)) {
+ for (const item of inlineList) {
+ const stmt = normalizeStatement(item, filenameForError)
+ if (stmt) statements.push(stmt)
+ }
+ }
+ }
+
+ if (statements.length === 0) {
+ throw new Error(`Rubric ${filenameForError} contains no statements`)
+ }
+
+ return { metadata, statements }
+}
+
+function extractMetadata(doc: unknown, filenameForError: string): RubricMetadata {
+ if (doc && typeof doc === 'object') {
+ const obj = doc as Record
+ // `variables` and `expressions` sit at the top level of doc 0, as
+ // siblings of `rubric_metadata`. Pull them out once here so both the
+ // wrapped and unwrapped shapes get the same treatment.
+ const variables = coerceStringKeyedObject(obj.variables)
+ const expressions = coerceStringExpressions(obj.expressions)
+
+ // Preferred: wrapped under `rubric_metadata`.
+ const wrapped = obj.rubric_metadata
+ if (wrapped && typeof wrapped === 'object') {
+ return coerceMetadata(wrapped as Record, variables, expressions)
+ }
+ // Fallback: metadata fields directly at the top level of doc 0.
+ if ('name' in obj) {
+ return coerceMetadata(obj, variables, expressions)
+ }
+ }
+ throw new Error(`Rubric ${filenameForError} is missing rubric_metadata`)
+}
+
+function coerceMetadata(
+ obj: Record,
+ variables: Record | undefined,
+ expressions: Record | undefined,
+): RubricMetadata {
+ return {
+ name: String(obj.name ?? 'Unnamed Rubric'),
+ issuer: obj.issuer != null ? String(obj.issuer) : undefined,
+ date: obj.date != null ? String(obj.date) : undefined,
+ version: obj.version != null ? String(obj.version) : undefined,
+ language: obj.language != null ? String(obj.language) : undefined,
+ variables,
+ expressions,
+ }
+}
+
+/** Accept any object with string keys. Returns `undefined` for null/arrays/non-objects. */
+function coerceStringKeyedObject(raw: unknown): Record | undefined {
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined
+ return raw as Record
+}
+
+/** Expressions must be `{ [name]: string }`; drop non-string values. */
+function coerceStringExpressions(raw: unknown): Record | undefined {
+ const obj = coerceStringKeyedObject(raw)
+ if (!obj) return undefined
+ const out: Record = {}
+ for (const [k, v] of Object.entries(obj)) {
+ if (typeof v === 'string') out[k] = v
+ }
+ return Object.keys(out).length > 0 ? out : undefined
+}
+
+function normalizeStatement(item: unknown, filenameForError: string): RubricStatement | null {
+ if (!item || typeof item !== 'object') return null
+ const obj = item as Record
+ const id = typeof obj.id === 'string' ? obj.id : null
+ const expression = typeof obj.expression === 'string' ? obj.expression : null
+ if (!id || !expression) {
+ // Malformed entry — skip rather than crash the whole file. Warn in dev.
+ console.warn(
+ `[rubrics] Skipping malformed statement in ${filenameForError}:`,
+ obj,
+ )
+ return null
+ }
+ return {
+ id,
+ description: typeof obj.description === 'string' ? obj.description : undefined,
+ expression,
+ failIfMatched: obj.failIfMatched === true,
+ reportText: (obj.reportText ?? undefined) as RubricStatement['reportText'],
+ }
+}
diff --git a/src/lib/rubrics/perManifest.test.ts b/src/lib/rubrics/perManifest.test.ts
new file mode 100644
index 0000000..c80cfa1
--- /dev/null
+++ b/src/lib/rubrics/perManifest.test.ts
@@ -0,0 +1,115 @@
+/**
+ * Unit-level behavior tests for the per-manifest signals evaluator.
+ *
+ * The *parity* assertions against `capture.signals.json` (and all other
+ * upstream fixtures) live in `goldens.test.ts`, which iterates the full
+ * fixture set. This file exercises pieces that aren't easily demonstrated
+ * by upstream fixtures — coercion edge cases, ingredient back-fill, and
+ * the strict "at least one actions assertion" rule for `allActionsIncluded`.
+ */
+
+import { describe, expect, it } from 'vitest'
+import type { CrJson } from '../crjson'
+import { parseRubricYaml } from './loader'
+import { evaluatePerManifest } from './perManifest'
+
+describe('per-manifest evaluator · local unit behavior', () => {
+ it('emits only truthy signals and groups by id prefix', () => {
+ const rubric = parseRubricYaml(
+ [
+ 'rubric_metadata:',
+ ' name: mini',
+ '---',
+ '- id: inception:always_true',
+ ' expression: "`true`"',
+ " reportText: { 'true': { en: yep } }",
+ '- id: transformation:always_false',
+ ' expression: "`false`"',
+ " reportText: { 'true': { en: nope } }",
+ '- id: inception:list_signal',
+ ' expression: |-',
+ " assertions.'c2pa.actions'.actions[?action == \"c2pa.created\"].action",
+ " reportText: { 'true': { en: created } }",
+ ].join('\n'),
+ 'inline',
+ )
+
+ const report: CrJson = {
+ manifests: [
+ {
+ label: 'urn:c2pa:test-1',
+ assertions: {
+ 'c2pa.actions': {
+ actions: [{ action: 'c2pa.created' }, { action: 'c2pa.created' }],
+ },
+ },
+ },
+ ],
+ } as unknown as CrJson
+
+ const result = evaluatePerManifest(rubric, report, { rubricId: 'mini' })
+ expect(result.manifests).toHaveLength(1)
+ const m = result.manifests[0]
+ expect(m.localInceptions.map((s) => s.trait)).toEqual([
+ 'inception:always_true',
+ 'inception:list_signal',
+ ])
+ // two 'c2pa.created' → list length 2 → multiple = true
+ expect(m.localInceptions.find((s) => s.trait === 'inception:list_signal')?.multiple).toBe(true)
+ expect(m.localTransformations).toEqual([])
+ })
+
+ it('resolves parent mimeType via child ingredient back-fill', () => {
+ const rubric = parseRubricYaml(
+ ['rubric_metadata:', ' name: empty', '---', '- id: x:never', " expression: \"`false`\"", " reportText: { 'true': { en: n } }"].join('\n'),
+ 'inline',
+ )
+ const report: CrJson = {
+ manifests: [
+ {
+ label: 'urn:c2pa:parent',
+ assertions: {},
+ },
+ {
+ label: 'urn:c2pa:child',
+ assertions: {
+ 'c2pa.ingredient.v3': {
+ 'dc:format': 'image/jpeg',
+ relationship: 'parentOf',
+ activeManifest: { url: 'self#jumbf=/c2pa/urn:c2pa:parent/c2pa.assertions/x' },
+ },
+ },
+ },
+ ],
+ } as unknown as CrJson
+
+ const result = evaluatePerManifest(rubric, report, { rubricId: 'empty' })
+ expect(result.manifests[0].mimeType).toBe('image/jpeg')
+ expect(result.manifests[1].ingredients).toEqual([{ index: 0, relationship: 'parentOf' }])
+ })
+
+ it('allActionsIncluded requires at least one actions assertion', () => {
+ const rubric = parseRubricYaml(
+ ['rubric_metadata:', ' name: e', '---', '- id: x:n', " expression: \"`false`\"", " reportText: { 'true': { en: n } }"].join('\n'),
+ 'inline',
+ )
+ const report: CrJson = {
+ manifests: [
+ { label: 'urn:c2pa:none', assertions: {} },
+ {
+ label: 'urn:c2pa:yes',
+ assertions: { 'c2pa.actions.v2': { actions: [], allActionsIncluded: true } },
+ },
+ {
+ label: 'urn:c2pa:partial',
+ assertions: { 'c2pa.actions.v2': { actions: [], allActionsIncluded: false } },
+ },
+ ],
+ } as unknown as CrJson
+
+ const result = evaluatePerManifest(rubric, report, { rubricId: 'e' })
+ expect(result.manifests[0].allActionsIncluded).toBe(false) // no actions → false
+ expect(result.manifests[1].allActionsIncluded).toBe(true)
+ expect(result.manifests[2].allActionsIncluded).toBe(false)
+ })
+})
diff --git a/src/lib/rubrics/perManifest.ts b/src/lib/rubrics/perManifest.ts
new file mode 100644
index 0000000..3bcf050
--- /dev/null
+++ b/src/lib/rubrics/perManifest.ts
@@ -0,0 +1,304 @@
+/**
+ * Per-manifest rubric evaluator — used for "signals" rubrics where every
+ * statement is a local predicate applied to a single manifest, and only
+ * *truthy* outcomes are reported.
+ *
+ * This is a direct port of the Python reference at
+ * `c2pa_signals_rubric_evaluator.py` (in asset-rubrics/). The algorithm:
+ *
+ * 1. Build a label → index mapping over `report.manifests`.
+ * 2. For every manifest, run each statement's json-formula expression
+ * with that manifest as the root. Only record signals where the
+ * result coerces to true.
+ * 3. Build the "mimeType" and ingredient-DAG metadata per manifest, so
+ * the UI can show ingredient edges, assertedBy, and allActionsIncluded.
+ *
+ * We intentionally do NOT share coerce() with `evaluate.ts` here: the
+ * document-mode evaluator exposes pass/fail + matches (including support
+ * for `failIfMatched`), while this mode is strictly "truthy emit, falsy drop".
+ * Diverging intents → diverging code; keeping them separate prevents surprise
+ * coupling when either evaluation mode grows.
+ */
+
+import type { CrJson, CrJsonManifestEntry } from '../crjson'
+import { createEngine, type RubricEngine } from './engine'
+import type {
+ AssertedBy,
+ IngredientEdge,
+ ManifestSignalsResult,
+ Rubric,
+ RubricStatement,
+ SignalHit,
+ SignalsRubricResult,
+} from './types'
+
+const DEFAULT_LOCALE = 'en'
+
+export function evaluatePerManifest(
+ rubric: Rubric,
+ report: CrJson,
+ options: { rubricId: string; locale?: string } = { rubricId: 'unknown' },
+): SignalsRubricResult {
+ const locale = options.locale ?? rubric.metadata.language ?? DEFAULT_LOCALE
+ const manifests = Array.isArray(report.manifests) ? report.manifests : []
+
+ // One engine for the whole evaluation: its custom `_expression()` functions
+ // are pure (they re-evaluate their AST against whatever `data` is passed),
+ // so we can safely reuse a single instance across all manifests.
+ const engine = createEngine(rubric.metadata)
+
+ // label → string index, mirroring the Python reference.
+ const indexMapping = buildIndexMapping(manifests)
+
+ // Derive per-manifest mime types, resolving parent fills from child
+ // ingredient assertions (same pass as the Python DAG builder).
+ const mimeTypes = resolveMimeTypes(manifests, indexMapping)
+
+ const manifestResults: ManifestSignalsResult[] = manifests.map((manifest, idx) => {
+ const signals = evaluateManifestSignals(manifest, rubric.statements, locale, engine)
+
+ const localInceptions: SignalHit[] = []
+ const localTransformations: SignalHit[] = []
+ for (const s of signals) {
+ if (s.trait.startsWith('inception:')) localInceptions.push(s)
+ else if (s.trait.startsWith('transformation:')) localTransformations.push(s)
+ }
+
+ return {
+ assertedBy: extractAssertedBy(manifest),
+ mimeType: mimeTypes[idx] ?? null,
+ localInceptions,
+ localTransformations,
+ allActionsIncluded: computeAllActionsIncluded(manifest),
+ ingredients: extractIngredients(manifest, indexMapping),
+ }
+ })
+
+ return {
+ rubricId: options.rubricId,
+ rubricName: rubric.metadata.name,
+ rubricVersion: rubric.metadata.version,
+ mode: 'per-manifest',
+ manifests: manifestResults,
+ evaluatedAt: new Date(),
+ }
+}
+
+// ── Signal evaluation ────────────────────────────────────────────────
+
+function evaluateManifestSignals(
+ manifest: CrJsonManifestEntry,
+ statements: RubricStatement[],
+ locale: string,
+ engine: RubricEngine,
+): SignalHit[] {
+ const hits: SignalHit[] = []
+ for (const stmt of statements) {
+ if (!stmt.expression || !stmt.id) continue
+
+ let val: unknown
+ try {
+ val = engine.search(stmt.expression.trim(), manifest)
+ } catch (e) {
+ // Match Python: log and skip; don't let one bad expression kill the run.
+ // eslint-disable-next-line no-console
+ console.warn(`[rubrics] Error evaluating ${stmt.id}:`, e)
+ continue
+ }
+
+ const { truthy, multiple } = coerceTruthy(val)
+ if (!truthy) continue
+
+ hits.push({
+ trait: stmt.id,
+ reportText: pickTrueText(stmt, locale) ?? stmt.id,
+ multiple,
+ })
+ }
+ return hits
+}
+
+/**
+ * Truthy-only coercion (mirror of the Python reference for signals mode):
+ * list → truthy if non-empty; multiple if length > 1
+ * bool → as-is
+ * number → truthy if > 0
+ * null → falsy
+ * other → truthy
+ */
+function coerceTruthy(val: unknown): { truthy: boolean; multiple: boolean } {
+ if (Array.isArray(val)) return { truthy: val.length > 0, multiple: val.length > 1 }
+ if (typeof val === 'boolean') return { truthy: val, multiple: false }
+ if (typeof val === 'number') return { truthy: val > 0, multiple: false }
+ if (val == null) return { truthy: false, multiple: false }
+ return { truthy: true, multiple: false }
+}
+
+function pickTrueText(stmt: RubricStatement, locale: string): string | undefined {
+ const dict = stmt.reportText
+ if (!dict) return undefined
+ const chosen = dict['true'] ?? dict.default
+ if (!chosen) return undefined
+ if (typeof chosen === 'string') return chosen
+ return chosen[locale] ?? chosen[DEFAULT_LOCALE]
+}
+
+// ── Metadata extraction ──────────────────────────────────────────────
+
+function extractAssertedBy(manifest: CrJsonManifestEntry): AssertedBy {
+ const sig = (manifest.signature ?? {}) as Record
+ const certInfo = (sig.certificateInfo ?? sig.certificate_info ?? {}) as Record
+ const subject = (certInfo.subject ?? {}) as Record
+
+ const CN = typeof subject.CN === 'string' ? subject.CN : 'Unknown CN'
+ const O = typeof subject.O === 'string' ? subject.O : 'Unknown O'
+ const OU = typeof subject.OU === 'string' ? subject.OU : undefined
+
+ const out: AssertedBy = { CN, O }
+ if (OU) out.OU = OU
+ return out
+}
+
+/**
+ * Mirror the Python reference: `allActionsIncluded` is true iff at least
+ * one actions assertion exists and every such assertion has
+ * `allActionsIncluded === true`. If no actions assertions are present,
+ * the value is false (not vacuously true).
+ */
+function computeAllActionsIncluded(manifest: CrJsonManifestEntry): boolean {
+ const assertions = (manifest.assertions ?? {}) as Record
+ let actionsFound = false
+ let allIncluded = true
+ for (const value of Object.values(assertions)) {
+ if (!value || typeof value !== 'object') continue
+ const obj = value as Record
+ if (!('actions' in obj)) continue
+ actionsFound = true
+ if (obj.allActionsIncluded !== true) {
+ allIncluded = false
+ break
+ }
+ }
+ return actionsFound && allIncluded
+}
+
+function extractIngredients(
+ manifest: CrJsonManifestEntry,
+ indexMapping: Map,
+): IngredientEdge[] {
+ const assertions = (manifest.assertions ?? {}) as Record
+ const edges: IngredientEdge[] = []
+
+ for (const [key, rawValue] of Object.entries(assertions)) {
+ if (!key.startsWith('c2pa.ingredient')) continue
+ if (!rawValue || typeof rawValue !== 'object') continue
+ const value = rawValue as Record
+
+ const manifestRef = (value.c2pa_manifest ?? value.activeManifest ?? {}) as Record
+ const url = typeof manifestRef.url === 'string' ? manifestRef.url : undefined
+ const relationship = typeof value.relationship === 'string' ? value.relationship : undefined
+
+ if (!url) continue
+
+ const parentUrn = parseParentUrn(url)
+ const parentIdx = indexMapping.get(parentUrn)
+ if (parentIdx == null) {
+ // Parent not present in this bundle — mirror Python's warning-only behavior.
+ // eslint-disable-next-line no-console
+ console.warn(`[rubrics] Could not map parent URN ${parentUrn} to index`)
+ continue
+ }
+ edges.push({ index: parentIdx, relationship })
+ }
+ return edges
+}
+
+/**
+ * Extract the manifest label from a JUMBF URL.
+ * Handles both new-style (urn:c2pa:UUID) and old-style (self#jumbf=/c2pa/) formats.
+ */
+function parseParentUrn(url: string): string {
+ const prefix = '/c2pa/'
+ const i = url.indexOf(prefix)
+ if (i >= 0) {
+ const after = url.slice(i + prefix.length)
+ const slash = after.indexOf('/')
+ return slash >= 0 ? after.slice(0, slash) : after
+ }
+ const urnIdx = url.indexOf('urn:c2pa:')
+ if (urnIdx >= 0) {
+ const tail = url.slice(urnIdx)
+ const slash = tail.indexOf('/')
+ return slash >= 0 ? tail.slice(0, slash) : tail
+ }
+ return url
+}
+
+function buildIndexMapping(manifests: CrJsonManifestEntry[]): Map {
+ const mapping = new Map()
+ manifests.forEach((m, idx) => {
+ if (m && typeof m.label === 'string') {
+ mapping.set(m.label, idx)
+ }
+ })
+ return mapping
+}
+
+/**
+ * Resolve a mime type for each manifest using the Python reference priority:
+ *
+ * 1. Own `claim.v2["dc:format"]` (or legacy `claim["dc:format"]`).
+ * 2. Own `c2pa.thumbnail*` assertion `format` field.
+ * 3. Any child manifest's ingredient assertion that names this manifest
+ * as its parent and carries a `dc:format` — but only if the parent
+ * does not already have a mime type set from (1) or (2).
+ *
+ * Returns one entry per manifest in the input order; `null` when no type
+ * could be resolved.
+ */
+function resolveMimeTypes(
+ manifests: CrJsonManifestEntry[],
+ indexMapping: Map,
+): (string | null)[] {
+ const out: (string | null)[] = manifests.map(() => null)
+
+ // Pass 1: own claim / thumbnail.
+ manifests.forEach((manifest, idx) => {
+ const claim = ((manifest['claim.v2'] ?? manifest.claim) ?? {}) as Record
+ const claimFormat = claim['dc:format']
+ if (typeof claimFormat === 'string' && claimFormat.length > 0) {
+ out[idx] = claimFormat
+ return
+ }
+ const assertions = (manifest.assertions ?? {}) as Record
+ for (const [key, value] of Object.entries(assertions)) {
+ if (!key.startsWith('c2pa.thumbnail')) continue
+ if (!value || typeof value !== 'object') continue
+ const fmt = (value as Record).format
+ if (typeof fmt === 'string' && fmt.length > 0) {
+ out[idx] = fmt
+ return
+ }
+ }
+ })
+
+ // Pass 2: ingredient back-fills. Only fills a parent that's still null.
+ manifests.forEach((manifest) => {
+ const assertions = (manifest.assertions ?? {}) as Record
+ for (const [key, rawValue] of Object.entries(assertions)) {
+ if (!key.startsWith('c2pa.ingredient')) continue
+ if (!rawValue || typeof rawValue !== 'object') continue
+ const value = rawValue as Record
+ const manifestRef = (value.c2pa_manifest ?? value.activeManifest ?? {}) as Record
+ const url = typeof manifestRef.url === 'string' ? manifestRef.url : undefined
+ const fmt = value['dc:format']
+ if (!url || typeof fmt !== 'string' || fmt.length === 0) continue
+
+ const parentIdx = indexMapping.get(parseParentUrn(url))
+ if (parentIdx == null) continue
+ if (out[parentIdx] == null) out[parentIdx] = fmt
+ }
+ })
+
+ return out
+}
diff --git a/src/lib/rubrics/types.ts b/src/lib/rubrics/types.ts
new file mode 100644
index 0000000..597c839
--- /dev/null
+++ b/src/lib/rubrics/types.ts
@@ -0,0 +1,183 @@
+/**
+ * Types for the rubric evaluation system.
+ *
+ * These mirror the schema of the Python reference evaluator at
+ * `/Users/andyp/Desktop/Projects/c2pa/conformance/asset-rubrics` so the same
+ * YAML rubrics can be evaluated by either runtime.
+ *
+ * Composition happens at build time in the Python toolchain: composables are
+ * flattened into a single pre-built YAML before we see it. At runtime we
+ * consume a flat list of statements — there is no reference resolution or
+ * cycle detection in this module.
+ */
+
+/** A single check within a rubric. Evaluates one json-formula expression. */
+export interface RubricStatement {
+ /** Stable identifier, usually "category:snake_case_name". */
+ id: string
+ /** Short human-readable description of the check. */
+ description?: string
+ /** json-formula expression evaluated against the crJSON context. */
+ expression: string
+ /**
+ * When true, a truthy/non-empty match means the check FAILED.
+ * (Used for "assert absence" checks like "no malformed failures present".)
+ */
+ failIfMatched?: boolean
+ /**
+ * Outcome → locale → text. Common keys: "true", "false", "default".
+ * Example:
+ * reportText:
+ * 'true': { en: 'No structural failures found' }
+ * 'false': { en: 'Found structural failures: {{matches}}' }
+ *
+ * The special token `{{matches}}` in the text is replaced with the list of
+ * match codes when the expression produced a list of strings.
+ */
+ reportText?: Record>
+}
+
+/** Metadata block from the first YAML document. */
+export interface RubricMetadata {
+ name: string
+ issuer?: string
+ date?: string
+ version?: string
+ /** Default locale for reportText selection. */
+ language?: string
+ /**
+ * Shared `$name → value` globals pulled from the rubric's top-level
+ * `variables:` block. Passed to every json-formula `search()` call so
+ * expressions can reference them as `$name`.
+ */
+ variables?: Record
+ /**
+ * Named reusable expressions from the rubric's top-level `expressions:`
+ * block. Registered as custom json-formula functions (`_name()`). Values
+ * may reference `$argN` positional parameters.
+ */
+ expressions?: Record
+}
+
+/** A full rubric — metadata + flat list of statements. */
+export interface Rubric {
+ metadata: RubricMetadata
+ statements: RubricStatement[]
+}
+
+/**
+ * How the rubric should be evaluated.
+ *
+ * - "document": the entire crJSON bundle is passed to json-formula. Expressions
+ * typically reference `manifests[0].*`. Each statement produces a single
+ * pass/fail for the whole asset. Used by integrity / conformance rubrics.
+ *
+ * - "per-manifest": the evaluator iterates `report.manifests[]` and passes
+ * each manifest to json-formula as the root. Only *positive* (truthy)
+ * outcomes are emitted, grouped by id prefix. Used by signals rubrics.
+ */
+export type EvaluationMode = 'document' | 'per-manifest'
+
+/** Entry in public/rubrics/index.json. */
+export interface RubricIndexEntry {
+ id: string
+ filename: string
+ name: string
+ description: string
+ /** Defaults to "document" when omitted, for backwards compatibility. */
+ mode?: EvaluationMode
+ /**
+ * UI grouping label shown as a divider heading in the rubric selector.
+ * Entries with the same `category` are rendered under one heading, in
+ * the order they appear in the index. Falls back to "Other" when absent.
+ */
+ category?: string
+}
+
+// ── Per-manifest (signals) result types ───────────────────────────────
+
+/** A single truthy signal fired on a manifest. */
+export interface SignalHit {
+ /** The statement id that fired (e.g. "inception:signal_capturedMedia"). */
+ trait: string
+ /** Locale-selected reportText for the "true" outcome. */
+ reportText: string
+ /** True when the matching list had more than one element. */
+ multiple: boolean
+}
+
+/** Who signed the manifest, from the certificate subject. */
+export interface AssertedBy {
+ CN: string
+ O: string
+ OU?: string
+}
+
+/** An ingredient edge pointing to a parent manifest in this bundle. */
+export interface IngredientEdge {
+ /** Index into `SignalsRubricResult.manifests`. */
+ index: number
+ /** e.g. "parentOf", "inputTo", "componentOf". */
+ relationship?: string
+}
+
+/** Per-manifest signals output. Mirrors the Python reference shape. */
+export interface ManifestSignalsResult {
+ assertedBy: AssertedBy
+ mimeType: string | null
+ localInceptions: SignalHit[]
+ localTransformations: SignalHit[]
+ /** True iff at least one actions assertion exists and all have allActionsIncluded === true. */
+ allActionsIncluded: boolean
+ ingredients: IngredientEdge[]
+}
+
+/** Aggregate signals rubric result across all manifests. */
+export interface SignalsRubricResult {
+ rubricId: string
+ rubricName: string
+ rubricVersion?: string
+ mode: 'per-manifest'
+ manifests: ManifestSignalsResult[]
+ evaluatedAt: Date
+}
+
+/** Discriminator for UI rendering. */
+export type AnyRubricResult =
+ | (RubricResult & { mode: 'document' })
+ | SignalsRubricResult
+
+/** Result of evaluating a single statement. */
+export interface StatementResult {
+ id: string
+ /** e.g. "validation" for a statement id of "validation:well_formed_success" */
+ category: string
+ /** Inherited from the statement for display. */
+ description?: string
+ /**
+ * The coerced boolean outcome. `null` means the expression errored and
+ * no outcome could be determined.
+ */
+ passed: boolean | null
+ /** Selected reportText, with `{{matches}}` substituted when applicable. */
+ message: string
+ /** Raw json-formula result (for debugging / UI deep-dive). */
+ rawValue: unknown
+ /** Error message if evaluation threw. */
+ error?: string
+}
+
+/** Result of evaluating an entire rubric. */
+export interface RubricResult {
+ rubricId: string
+ rubricName: string
+ rubricVersion?: string
+ /**
+ * True iff every statement with a definite boolean outcome passed.
+ * Statements that errored (passed === null) do not count as pass.
+ */
+ overallPassed: boolean
+ statements: StatementResult[]
+ /** When the evaluation ran. */
+ evaluatedAt: Date
+}
diff --git a/src/lib/summarySignals.ts b/src/lib/summarySignals.ts
new file mode 100644
index 0000000..acd1fe4
--- /dev/null
+++ b/src/lib/summarySignals.ts
@@ -0,0 +1,56 @@
+/**
+ * Async helper that loads the signals rubric and evaluates it against a
+ * crJSON report. Used by `ManifestSummary.svelte` to power the rubric-driven
+ * `generateManifestSummary()`.
+ *
+ * The signals rubric is loaded **once per session** and cached in-memory:
+ * the YAML is small (~7KB) and shared across every manifest summary on
+ * the page. The browser will additionally cache the HTTP fetch via the
+ * normal cache-control headers Vite sets.
+ *
+ * Failures are non-fatal — if the rubric can't be loaded for any reason
+ * (e.g. offline preview, bad deploy), we resolve to `null` and the
+ * caller renders its generic fallback summary.
+ */
+
+import type { CrJson } from './crjson'
+import { loadRubric } from './rubrics/loader'
+import { evaluatePerManifest } from './rubrics/perManifest'
+import type { Rubric, SignalsRubricResult } from './rubrics/types'
+
+const SIGNALS_RUBRIC_FILE = 'asset-rubric-signals-local.yml'
+const SIGNALS_RUBRIC_ID = 'asset-signals-local'
+
+let rubricPromise: Promise | null = null
+
+/** Load (and cache) the signals rubric. Resolves to `null` on any failure. */
+function loadSignalsRubric(): Promise {
+ if (rubricPromise) return rubricPromise
+ rubricPromise = loadRubric(SIGNALS_RUBRIC_FILE).catch((err) => {
+ // eslint-disable-next-line no-console
+ console.warn('[summarySignals] Failed to load signals rubric:', err)
+ // Reset so a transient failure doesn't poison the cache for the rest
+ // of the session — next call will retry.
+ rubricPromise = null
+ return null
+ })
+ return rubricPromise
+}
+
+/**
+ * Evaluate the signals rubric against the report. Returns the full result
+ * (per-manifest signal hits + assertedBy + ingredient edges) or `null` if
+ * the rubric couldn't be loaded.
+ */
+export async function evaluateReportSignals(
+ report: CrJson,
+): Promise {
+ const rubric = await loadSignalsRubric()
+ if (!rubric) return null
+ return evaluatePerManifest(rubric, report, { rubricId: SIGNALS_RUBRIC_ID })
+}
+
+/** Test seam — wipe the cached rubric so a fresh fetch happens next call. */
+export function _resetSignalsCacheForTests(): void {
+ rubricPromise = null
+}
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 6ee0009..05d71b5 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -3,7 +3,7 @@
* Report format is crJSON (native) + conformance-tool metadata.
*/
-import type { CrJson } from './crjson'
+import type { CrJson, CrJsonSignatureInfo } from './crjson'
export type {
CrJson,
@@ -36,9 +36,47 @@ export interface ValidationStatusItem {
code: string
success: boolean
isInterim?: boolean
+ isInformational?: boolean
explanation?: string
}
+/** Node in the overview provenance tree */
+export interface OverviewNode {
+ manifestIdx: number
+ claimGenerator?: string
+ mimeType?: string | null
+ thumbnailSrc?: string
+ date?: string
+ ingredientCount: number
+ inceptions: string[]
+ transformations: string[]
+ relationship?: string
+ isStub?: boolean
+ children: OverviewNode[]
+}
+
+/** Node in the ingredient provenance tree */
+export interface IngredientTreeNode {
+ title: string
+ format?: string
+ relationship?: string
+ thumbnailSrc?: string
+ claimGenerator?: string
+ isRoot: boolean
+ children: IngredientTreeNode[]
+}
+
+/** Grouped validation status by manifest */
+export interface ManifestValidationGroup {
+ label: string
+ isActive: boolean
+ index: number
+ sigInfo?: CrJsonSignatureInfo
+ success: ValidationStatusItem[]
+ failure: ValidationStatusItem[]
+ informational: ValidationStatusItem[]
+}
+
/** Assertion summary row for display */
export interface AssertionSummaryItem {
key: string
diff --git a/src/test/setup.ts b/src/test/setup.ts
index 8ba2c37..40b8898 100644
--- a/src/test/setup.ts
+++ b/src/test/setup.ts
@@ -9,3 +9,10 @@ afterEach(() => {
// Mock fetch for tests
global.fetch = vi.fn() as any
+
+// Mock ResizeObserver (used by Svelte bind:clientWidth, not available in jsdom)
+global.ResizeObserver = class ResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
diff --git a/vitest.config.ts b/vitest.config.ts
index 8919dab..c63f66e 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -3,6 +3,12 @@ import { svelte } from '@sveltejs/vite-plugin-svelte'
export default defineConfig({
plugins: [svelte({ hot: !process.env.VITEST })],
+ resolve: {
+ alias: {
+ '$lib': '/src/lib'
+ },
+ conditions: process.env.VITEST ? ['browser'] : []
+ },
test: {
globals: true,
environment: 'jsdom',
@@ -19,10 +25,5 @@ export default defineConfig({
'**/dist'
]
}
- },
- resolve: {
- alias: {
- '$lib': '/src/lib'
- }
}
})
diff --git a/wasm/src/lib.rs b/wasm/src/lib.rs
index d76f914..5c8f86b 100644
--- a/wasm/src/lib.rs
+++ b/wasm/src/lib.rs
@@ -7,18 +7,22 @@ pub fn init() {
console_error_panic_hook::set_once();
}
+fn build_context(settings_json: Option) -> Result {
+ match settings_json {
+ Some(json) if !json.trim().is_empty() => Context::new()
+ .with_settings(json)
+ .map_err(|e| JsValue::from_str(&format!("Failed to parse C2PA settings: {e}"))),
+ _ => Ok(Context::new()),
+ }
+}
+
#[wasm_bindgen]
pub async fn read_manifest_store(
file_bytes: Vec,
format: String,
settings_json: Option,
) -> Result {
- let context = match settings_json {
- Some(json) if !json.trim().is_empty() => Context::new()
- .with_settings(json)
- .map_err(|e| JsValue::from_str(&format!("Failed to parse C2PA settings: {e}")))?,
- _ => Context::new(),
- };
+ let context = build_context(settings_json)?;
let reader = Reader::from_context(context)
.with_stream_async(&format, Cursor::new(file_bytes))
@@ -28,6 +32,74 @@ pub async fn read_manifest_store(
Ok(reader.crjson())
}
+/// Validate a detached (`.c2pa`) manifest store against its referenced asset.
+///
+/// This is the sidecar-with-asset case: the C2PA manifest lives in its own file
+/// (`manifest_bytes`) and the asset whose hash-bindings the manifest claims
+/// lives separately (`asset_bytes`). We feed both into c2pa-rs's
+/// `with_manifest_data_and_stream_async`, which evaluates the asset-hash
+/// assertions *against the actual asset bytes* — something we cannot do with
+/// the single-blob `read_manifest_store` path.
+///
+/// * `manifest_bytes` - raw bytes of the `.c2pa` sidecar (JUMBF manifest store).
+/// * `asset_bytes` - raw bytes of the referenced asset.
+/// * `asset_format` - MIME type of the asset (e.g. "image/jpeg"). The
+/// sidecar's own format is always `application/c2pa` and the SDK infers that.
+/// * `settings_json` - trust settings (same shape as `read_manifest_store`).
+#[wasm_bindgen]
+pub async fn read_sidecar_manifest_store(
+ manifest_bytes: Vec,
+ asset_bytes: Vec,
+ asset_format: String,
+ settings_json: Option,
+) -> Result {
+ let context = build_context(settings_json)?;
+
+ let reader = Reader::from_context(context)
+ .with_manifest_data_and_stream_async(
+ &manifest_bytes,
+ &asset_format,
+ Cursor::new(asset_bytes),
+ )
+ .await
+ .map_err(|e| {
+ JsValue::from_str(&format!("Failed to validate sidecar against asset: {e}"))
+ })?;
+
+ Ok(reader.crjson())
+}
+
+/// Inspect a detached (`.c2pa`) manifest store without an asset.
+///
+/// Used when the user drops a sidecar without the matching asset. We feed
+/// the manifest bytes to `with_manifest_data_and_stream_async` paired with
+/// an empty stream. The signature, certificate chain, and JUMBF structure
+/// are validated normally; the asset-hash bindings will report
+/// `assertion.dataHash.mismatch` because there is no asset to bind to —
+/// that is expected, and callers should label this as an integrity-only
+/// inspection in the UI.
+///
+/// * `manifest_bytes` - raw bytes of the `.c2pa` sidecar (JUMBF manifest store).
+/// * `settings_json` - trust settings (same shape as `read_manifest_store`).
+#[wasm_bindgen]
+pub async fn read_sidecar_integrity_only(
+ manifest_bytes: Vec,
+ settings_json: Option,
+) -> Result {
+ let context = build_context(settings_json)?;
+
+ let reader = Reader::from_context(context)
+ .with_manifest_data_and_stream_async(
+ &manifest_bytes,
+ "application/octet-stream",
+ Cursor::new(Vec::::new()),
+ )
+ .await
+ .map_err(|e| JsValue::from_str(&format!("Failed to inspect sidecar manifest: {e}")))?;
+
+ Ok(reader.crjson())
+}
+
/// Get version information
#[wasm_bindgen]
pub fn get_version() -> String {