Topic-ordered reference distilled from the spike log (NOTES.md, indexing the
per-step files in notes/). Everything here was verified against running code
(c2patool 0.27.15, @contentauth/c2pa-node 0.9.1, c2pa-rs test certs), first on
2026-07-27, last reconciled in full on 2026-08-12, and re-run against those two
tool versions on 2026-08-21 — none of it is from model memory. §11 was added
on 2026-08-31 and is measured against c2patool 0.27.16; the rest of the page
still carries the 0.27.15 verification, which is a difference worth keeping
visible rather than smoothing over. The engine moved to
@contentauth/c2pa-node 0.9.5 / c2pa-rs 0.90.22 on 2026-09-12 (NOTES Step 64,
and before that to 0.9.3 / 0.90.16 on 2026-09-03, NOTES Step 55); the §4, §9 and
§11 claims were re-measured against it, the rest was not re-run. §8 was
re-measured on 2026-09-05 (NOTES Step 58) and one of its claims was wrong:
the declared media type is not advisory and nothing sniffs the bytes — it
selects a handler, and a handler covers a family. That bullet is corrected in
place.
When this page and the log disagree, the log (the raw record) wins; fix this
page. When neither answers a question, ask — do not guess.
§3 is reconciled against service/server.js itself, not against the log.
The 2026-08-05 reconciliation was log-only, so it could not catch a table that
had never matched the code and did not catch SPEC-028's parent, which landed
on 08-07. For the request contract, the handler is the raw record: read the
destructuring at the top of app.post('/v1/sign').
- c2pa-rs ≥ 0.90 / c2pa-node ≥ 0.7 emit claim version 2 manifests.
- The actions assertion label is
c2pa.actions.v2(notc2pa.actions). - Claim v2 REQUIRES an actions assertion whose first action is
c2pa.createdorc2pa.opened; anything else →assertion.action.malformed: "first action must be created or opened". - c2pa-node auto-adds a
c2pa.thumbnail.claimassertion. Expected; harmless — but see §11 for where it lands, which is not where it belongs. - A claim v2 splits assertions into
created_assertionsandgathered_assertions, and which one an assertion lands in is a claim about who stands behind it. See §11.
The canonical assertion for "this asset is AI-generated":
{
"label": "c2pa.actions.v2",
"data": { "actions": [
{ "action": "c2pa.created",
"digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia",
"softwareAgent": { "name": "<generator name>" } }
] }
}digitalSourceTypeMUST be the full IPTC URI shown above (introduced in C2PA spec 1.3; also valid inside a v1 actions assertion).- Convenient coincidence: this single assertion satisfies BOTH the Article 50 marking AND claim-v2 well-formedness (first action = created).
POST /v1/sign — Authorization: Bearer $CONTENTAUTH_API_KEY, JSON body:
| field | required | meaning |
|---|---|---|
content |
yes | base64 of the raw file bytes |
mime_type |
yes | must be in SUPPORTED_MIME, else 400 |
creator_name |
no | prepended to claim_generator; string, MAX_CREATOR_NAME (256) |
extra_assertions |
no | raw C2PA assertion objects, appended; vetted per SPEC-011 |
parent |
conditional | {content, mime_type} of the source asset — the SPEC-028 ingredient |
That is the whole body: server.js destructures exactly these five and ignores
anything else. parent is mandatory-by-manifest, both ways (SPEC-028 AC5):
needsParentAsset(extra_assertions) decides, because c2pa-rs enforces neither
direction — an edit intent with no ingredient signs and reports Valid, and so
does a c2pa.created action sitting next to a parentOf ingredient. Those
guards are the only ones there are.
signature_type, org_name and org_url are NOT fields of this service.
They belong to the upstream CAI wp-plugin contract, and this table listed them
until 2026-08-12 as though our service read them. It does not: grep -in cawg service/server.js returns nothing. CAWG organisational identity is unbuilt on
both sides — out of scope in SPEC-002, named "the natural next step" by
SPEC-016, and it needs its own spec (ADR-0004 left createCawgTrustSettings
deliberately unused). Do not write a client that sends them.
Response: { "signed_content": "<base64>", "manifest_url": null }.
Also: POST /v1/read ({content, mime_type}, same SUPPORTED_MIME check →
decoded manifest) and public GET /health.
Deliberate divergence from the CAI wp-plugin contract: our service does
NOT inject a hardcoded c2pa.published actions assertion. The PHP client
supplies the actions assertion via extra_assertions, so the manifest carries
exactly one, correct actions assertion. (Upstream's service is unrunnable
scaffolding anyway — see NOTES.md Step 1 for the four blockers, re-measured
unchanged on 2026-09-08.)
created: true to that assertion (markActionsAsCreated() in
server.js) before handing it to the builder. The division is: contents are
the client's, placement in the claim is the generator's — created means
"attributed to the signer", and the signer is the service. See §11.
- Maintained package:
@contentauth/c2pa-node(currently 0.9.5, carrying c2pa-rs 0.90.22). The old unscopedc2pa-nodeis EOL at 0.5.26 — never depend on it. Thecontentauth/c2pa-node-v2repo was archived ~2026-06-08; development and the real CHANGELOG moved to thecontentauth/c2pa-jsmonorepo underpackages/c2pa-node, while npm keeps publishing from there. Do not read version history off the archived repo — its tags stop at v0.5.5. - Signer:
LocalSigner.newSigner(certChainBuf, privateKeyBuf, "es256" [, tsaUrl]). - Timestamping requires the async path (SPEC-007, implemented). Passing a
tsaUrland then calling the synchronousbuilder.sign(...)fails withthe sync http resolver is not implemented— fetching the RFC 3161 token is an HTTP call. With a TSA the service usesCallbackSigner.newSigner({ alg, certs, reserveSize: 20000, tsaUrl, directCoseHandling: false }, cb)plusawait builder.signAsync(...); without one it keeps the syncLocalSigner. An unreachable TSA fails closed — there is no untimestamped fallback. - Build:
Builder.withJson({ claim_generator_info, format, assertions, ... })orbuilder.addAssertion(label, data). - Critical gotcha:
builder.sign(signer, source, dest)RETURNS the JUMBF manifest-store bytes, NOT the signed asset. The signed asset is written todest({path}or{buffer}). Returning sign()'s value as the file yields a broken "image" whose read-back fails with header6A 75 6D 62(ASCII "jumb"). - Read/verify:
Reader.fromAsset({buffer, mimeType})→.json(),.getActive(). - Gotcha (SPEC-010):
Reader.fromAsset()resolves tonull— it does not throw — for an asset with no C2PA manifest, so an unguarded.json()crashes.POST /v1/readreturns{}in that case, which decodes client-side to an emptyManifestReport(hasManifest() === false) per the SPEC-003 contract.
- Test material comes from
contentauth/c2patool,sample/— it lived in c2pa-rscli/sample/until upstream split the CLI into its own repository (measured 2026-09-12: the old path 404s, the file at the new one is byte-identical). It holdses256_certs.pem+es256_private.key(ES256, soCONTENTAUTH_SIGN_ALG=es256), plustrust_anchors.pem,allowed_list.pem,store.cfg. - "Valid signature" ≠ "trusted cert". Test certs always produce
signingCredential.untrustedunder default verification; the signature itself is still cryptographically valid. - Trusted verification needs BOTH
verify.verify_trust = trueAND trust material:trust.trust_anchors(CA PEM) +trust.trust_config(allowed EKU OIDs), ortrust.allowed_list. - Settings gotcha: the
trust.*fields take PEM/EKU file contents as strings, not paths.certs/c2pa-trust.settings.jsonembeds them;bin/verify.shwrapsc2patool --settingswith that file. Expected clean result:validation_status: []. - EKU note: the test leaf cert's EKU is E-mail Protection
(1.3.6.1.5.5.7.3.4);
store.cfglists the EKUs C2PA permits, so the chain passes. - NEVER run
c2patool init trustin this project: it fetches the PRODUCTION trust list, which (correctly) rejects test certs. - Committable: public test CA certs, trust settings JSON. Gitignored forever:
es256_private.key. Real/production keys never enter the repo, any branch, any fixture.
Signing embeds a hash binding over the asset's byte ranges. ANY post-sign mutation — re-encode, optimize, resize, metadata rewrite — invalidates the manifest. Applies to code paths AND documentation examples.
- npm allow-scripts may block
@contentauth/c2pa-node's postinstall (which fetches the native binary). Fix:npm approve-scripts @contentauth/c2pa-nodeornpm rebuild. Non-issue in Docker (scripts run at build). - PHP target is ^8.3; dev machines may run 8.5. Use no 8.4/8.5-only features.
curl_close()is a deprecated no-op — do not call it.
The engine was never limited to PNG and JPEG; two hand-written allow-lists
were. Each type below was signed, read back Valid with the Article 50 marking
intact, and confirmed with c2patool under trust settings:
image/png, image/jpeg, image/webp, image/avif, image/gif,
image/tiff, image/svg+xml, audio/wav, audio/mpeg, audio/flac,
video/mp4, video/quicktime, video/x-msvideo.
-
audio/mp3,audio/x-flac,video/aviandaudio/x-wavare accepted as input spellings, normalised to the registered types. The last is the one you are most likely to meet without looking for it: PHP'sfinfoand Drupal core both report every.wavasaudio/x-wav(SPEC-041, measured). -
SVG is signable but fragile: SVGO's default preset removes the manifest silently, and re-serialising the XML makes the file unparseable as C2PA. Sign it as a deliverable, never as a build asset (SPEC-023, measured).
-
Not supported, each for its own reason (NOTES Step 27):
application/pdf— c2pa-rs registers readers and writers separately and PDF is read-only upstream, though the C2PA spec does define PDF embedding;video/webm— no Matroska handler at all;image/x-adobe-dng— unmeasured. JPEG XL is measured as of 2026-09-05 and stays unsupported: a bare codestream cannot carry a manifest at all (c2pa-rs says so itself), and the container form only "signs" through the BMFF handler, unreadably — see the warning below. -
The declared media type selects a handler; it is not advisory, and nothing sniffs the bytes (re-measured 2026-09-05, NOTES Step 58 — this bullet said the opposite until then). The type you declare picks the handler, and that handler then validates the container signature and refuses anything else. What is true is that one handler covers a family of formats, which is why the old example held: a WAV offered as
image/webpsigns, because WAV and WebP are both RIFF. Offer a JPEG XL codestream asimage/webpand the same handler answerserror parsing RIFF: expected "RIFF". So a type is only interchangeable with its family:- RIFF —
audio/wav,image/webp,video/x-msvideo - ISOBMFF —
video/mp4,video/quicktime,image/avif, and see the warning below - everything else answers for itself (
image/png,image/jpeg,image/gif,image/tiff,audio/flac, …)
The 400 the service returns for e.g.
image/bmpstill comes from our own allow-list, not from c2pa. - RIFF —
-
⚠️ A JPEG XL container signs asvideo/mp4, and the result is unreadable (measured 2026-09-05). A JXL container is ISOBMFF, so the BMFF handler accepts it:/v1/signreturns 200 and a plausiblesigned_content, the file still decodes as an image, and a 21 KBuuidbox is written. But nothing reads the manifest back — c2patool as.jxland as.mp4both sayNo claim found, and/v1/readreturns{}undervideo/mp4and underimage/png. Not even the handler that wrote it. This is a silent wrong success, not an error path, and narrowing it (checking theftypbrand) is a behaviour change that needs its own spec. Do not rely onvideo/mp4refusing non-MP4 ISOBMFF input. -
video/mp4is a container, not video support.MAX_BODY_SIZE(20 MB) and the ~7× memory multiplier apply to every type, and the transport is base64 in one HTTP body. Small clips only; the 413 says so. -
Three lists must agree:
MediaType,SUPPORTED_MIMEinservice/server.js(compared throughGET /health), and the extension map inInfersMediaType. -
Unmeasured, therefore undeclared: DNG. JPEG XL is now measured and stays undeclared — for the reasons two bullets up, not for lack of a measurement.
SigningServiceReader (HTTP, c2pa-rs 0.90.22) and ExtC2paReader (in-process,
0.89.0) answer the same questions. Two differences matter when choosing:
- Engine version. The extension lags the service, which is why
autois not the default (SPEC-020). - Process boundary. The extension parses untrusted assets inside the application process; the service reader keeps that in a separate one. This is the mirror image of ADR-0003's key-isolation argument, and it is a deliberate trade rather than a free operational win (SPEC-025 AC6).
Emittable — all three ride on the single c2pa.created action:
trainedAlgorithmicMedia, compositeSynthetic, algorithmicMedia.
Declared but refused by the builder, because C2PA records them as c2pa.opened
- an ingredient (
parentOf) +c2pa.edited, which this package cannot build:compositeWithTrainedAlgorithmicMedia,algorithmicallyEnhanced,humanEdits.
Absent on purpose: every authenticity term (digitalCapture,
computationalCapture, digitalCreation, film and print). A web application
receives bytes and cannot know a physical origin; signing such a claim turns
hearsay into attestation.
⚠️ URIs come fromcv.iptc.org, never from a document quoting it. The C2PA Implementation Guidance misspells one ascompositedWithTrainedAlgorithmicMedia(with a "d"); IPTC has never registered that term.⚠️ compositeWithTrainedAlgorithmicMediameans edited with generative AI, not "contains AI elements" — that iscompositeSynthetic.- Reading:
isAiGenerated()means exactlytrainedAlgorithmicMedia;involvesGenerativeAi()is the wider question and is false foralgorithmicMedia. - Retired, never emit:
minorHumanEdits,digitalArt,softwareImage.
Measured 2026-08-31 against c2patool 0.27.16 (c2pa-rs 0.90.16) and
@contentauth/c2pa-node 0.9.1, re-measured unchanged on 2026-09-03 against
0.9.3 (which carries that same engine) and again on 2026-09-12 against 0.9.5
(c2pa-rs 0.90.22 — six engine releases, and none of it moved), and read against
the normative text of the 2.3 and 2.4 specifications rather than their change
logs — the distinction mattered, see below.
A claim v2 has two assertion arrays, and the difference is attribution.
created_assertions holds what the claim generator made and stands behind:
"All created_assertions are attributed to the signer." gathered_assertions
holds assertions "provided to the claim generator by other components in the
workflow", and the specification's NOTE is explicit that putting one there
declares it "was not sourced from the claim generator and is not attributed to
the signer".
2.3 and 2.4 differ by one word, and it is the word that matters to us. §18.15.2, verbatim:
- 2.3 — "at least one actions assertion present in either the created_assertions or gathered_assertions array"
- 2.4 — "at least one actions assertion present in the created_assertions array"
For a package whose point is one actions assertion saying "made by a machine",
gathered is close to the opposite of what is meant.
"created": true on the assertion is what moves it. Not a global setting and
not an API call — a field on the assertion in the manifest definition JSON, read
by #[serde(default)] in sdk/src/manifest_assertion.rs. Three other
mechanisms were tried first and none of them worked: builder.created_assertion_labels
in the settings, the Create builder intent, and a search for an
add_created_assertion builder method. The service sets this flag, not the
client, because "attributed to the signer" is a statement about the signer.
specVersion lives in claim_generator_info, and the engine never sets it.
spec_version is Option<String> in sdk/src/claim.rs, None in three
constructors, serialised only when set — opt-in, and c2patool does not opt in. If
the value appears, a claim generator chose it. 2.3 §10.2.2: the field "may be
present, and if so, shall contain a SemVer formatted specVersion field", so
2.4.0 and never 2.4.
validation_state: Valid proves nothing about any of this. c2pa-rs does
not validate the placement rules. A manifest with its actions assertion in
gathered_assertions — violating 2.4 §18.15.2 — reads back Valid with no
status entry about it. Nor does anything validate the declared specVersion
against what the manifest actually does: claim_generator_info is an open map.
Both are claims, and a green verdict is not evidence for either. Assert structure
directly.
gathered_assertions, though the
generator made it. That contradicts what the field means, at 2.3 as much as at
2.4, and it is c2pa-rs's default — every tool built on it, c2patool included.
Tracked upstream as c2pa-rs #2106,
whose fix moves the thumbnail rather than removing it. Suppressible via
builder.thumbnail.enabled: false, which leaves gathered_assertions absent
entirely — deliberately not done; see SPEC-036 AC5.
Where the created/gathered split is visible. Only through
c2patool --detailed. Neither POST /v1/read nor ExtC2paReader surfaces those
arrays, so tests assert the created flag on the read-back assertion instead —
which is observable everywhere, and is what we control.
c2pa.actionsvsc2pa.actions.v2naming in public API/docs wording.
Closed since the spike: TSA timestamping (SPEC-007, implemented — see §4), manifest-less reads (SPEC-010, implemented — see §4) and asset types beyond PNG/JPEG (SPEC-021, implemented — see §8).