Skip to content

Disclosure SDK and evidence log

llms.txtAsk agent

Some laws ask you to tell people when they are talking to an AI system, or when content they see was generated or manipulated by one. The disclosure SDK helps you show those notices and labels, and the evidence log records that you showed them.

@originalpictures/disclosure (with a Python mirror for server-rendered pages) bundles the current policy document offline and provides:

  • <op-ai-notice>: a first-interaction notice for a chatbot or voice agent (“You are chatting with an AI system.”), with the text, form and timing the selected jurisdiction’s rule asks for.
  • <op-ai-label>: a visible label for generated or manipulated media, using one of four badge sets: AI-generated, AI-manipulated, AI interaction and AI-assisted. Each comes in brand light, brand dark and neutral variants. These are our badge designs, not an official EU mark.
  • badge(asset): overlays a visible label on an image in the browser. Nothing on the server is changed.

Both elements log an event to the evidence log when they are shown or acknowledged, once you give them a publishable key:

<script
src="https://cdn.jsdelivr.net/npm/@originalpictures/disclosure@0.1.0/dist/disclosure.iife.js"
defer
></script>
<op-ai-notice jurisdiction="auto" publishable-key="opk_…"></op-ai-notice>
<op-ai-label badge="ai-generated" kind="deepfake_label" publishable-key="opk_…"></op-ai-label>

Both elements take jurisdiction (auto or an id from the table below), rule, locale, region and publishable-key; <op-ai-label> also takes badge, variant (brand-light, brand-dark or neutral) and kind (visible_label or deepfake_label). With a bundler, import "@originalpictures/disclosure"; defines both elements, and badge(img, { badge: "ai-generated", variant: "brand-light" }) overlays a label. Install it with npm i @originalpictures/disclosure (0.1.0 on npm since 2026-11-10); the Python counterpart is pip install originalpictures-disclosure (0.1.0 on PyPI).

GET /v1/disclosures/policies serves the current policy document without a key; ?version= selects an earlier one. Its schema is op.disclosure-policy.2026-10.v1, and each rule names its instrument, provision, obligated party, trigger, timing, form, texts, badge set and exceptions.

jurisdiction Jurisdiction Instruments (draft citations)
EU European Union AI Act Art. 50(1), (2), (4), (5)
US-CA California SB 942 AI Transparency Act as amended by AB 853 and SB 1000 (latent disclosure, disclosure verification tool); SB 1001 bot disclosure
US-ME Maine LD 1727 (2025), AI chatbot disclosure
US-NH New Hampshire HB 1596 (2024), synthetic media in political advertising
US-NY New York GBL Art. 47 (2025) AI companion models; synthetic-performer advertising disclosure (2025)
US-UT Utah SB 149 (2024) AI Policy Act; SB 226 (2025)
IN India IT (Intermediary Guidelines) Rules, synthetically generated information
CN China Measures for Labeling AI-Generated Synthetic Content (2025); GB 45438-2025
KR Republic of Korea Framework Act on AI (2025), Art. 31

The served document is authoritative for the exact citations; this table is a summary.

Policy 2026-10-19.2 is current. It follows California SB 1000 (approved 30 September 2026), which deleted the duty to offer users a visible (manifest) disclosure, added “whether the system created or altered the content” to the latent disclosure, and replaced the detection tool with a free, public disclosure verification tool. 2026-10-19.1, which still lists the visible-label option, is served with ?version=2026-10-19.1, and events that name it keep validating. Every rule is still a draft pending legal review.

POST /v1/disclosures/events (scope disclosure:write, with an Idempotency-Key header) appends one event:

Terminal window
curl -sS -X POST "$OP_API_BASE_URL/v1/disclosures/events" \
-H "Authorization: Bearer $OP_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"kind": "ai_interaction_notice",
"jurisdiction": "EU",
"policy_version": "2026-10-19.2",
"rule_id": "eu.art50.1.ai-interaction",
"subject_ref": "session:7f3a9c",
"content_ref": null,
"client_time": "2026-10-19T09:00:00.000000Z",
"surface": "web",
"locale": "en"
}'
Field Required Notes
kind yes ai_interaction_notice, visible_label_offered, visible_label_applied or user_acknowledged.
jurisdiction yes One of the ids in the table above. Any other id is refused.
policy_version yes The policy document’s policy_version, such as 2026-10-19.2.
rule_id no The rule the notice or label applied.
subject_ref yes A pseudonymous reference to the person or session, up to 128 of A-Z a-z 0-9 . _ : ~ -.
content_ref no A pseudonymous reference to the content that was labelled, in the same alphabet.
client_time yes When your client showed it. Recorded beside the server’s own time, never instead of it.
surface no web, app, voice, api or other.
locale no A language tag such as en or de-AT.

subject_ref and content_ref must never be an email address, a name or any other personal data: use your own opaque identifiers. The answer is 201 with a receipt:

{
"event_id": "dse_…",
"sequence": 42,
"prev_hash": "sha256:…",
"hash": "sha256:…",
"received_at": "2026-10-19T09:00:00.412311Z"
}

Replaying the same Idempotency-Key with the same body returns the original receipt; a different body under the same key is 422 DISCLOSURE_INVALID naming the Idempotency-Key header. An unknown kind, jurisdiction, policy version or rule, or a malformed reference or time, is also 422 DISCLOSURE_INVALID.

Keep the receipts. They are your own copy of the chain’s links: see what the log does not prove.

A page can log its own events with a publishable key: issue one with {"preset": "publishable_disclosure"} on POST /v1/api-keys. Its only scope is disclosure:write, so it can do nothing but append events, and the endpoint answers a CORS preflight from any origin. Give it a rate limit (for example 600 a minute), because anyone who views the page can read it. Never put a full access key in a browser.

A publishable key is public by design. Anyone who copies it from your page can try to use it. Name the sites the key belongs to in restrictions.allowed_origins when you issue it (or later, on PUT /v1/api-keys/{api_key_id}/restrictions), and the API refuses any request whose Origin header names another site:

{
"preset": "publishable_disclosure",
"restrictions": {
"rate_limit_per_minute": 600,
"allowed_origins": ["https://studio-nova.example", "https://*.studio-nova.example"]
}
}

Each entry is a browser origin, scheme://host[:port] with no path. https://*.studio-nova.example covers every subdomain of studio-nova.example, but not studio-nova.example itself. Scheme, host and port must match exactly. A request from another site, or with no Origin at all, is refused with 403 API_KEY_ORIGIN_NOT_ALLOWED, and the refusal carries Access-Control-Allow-Origin: * so your page can read it. Only a publishable key (scope disclosure:write alone) takes an allowlist; on any other key, allowed_origins is refused with 422.

A browser writes Origin itself, and a page’s script cannot change it, so the allowlist stops another site using a copied key. It does not stop a server-side script, which can send any header it likes: an event in the log shows that a holder of your key sent it, and, when the key has an allowlist, which of your sites the request named. It does not show that your page displayed the disclosure. Watch the log for events you did not send, and revoke and replace the key if you find any.

A publishable_disclosure key issued without expires_at never expires; it stops working only when you revoke or rotate it. If you set an expires_at, or the key was issued while the default was 90 days (check expires_at in its metadata), it stops working at that time, and from then on every event is refused with 401. The components then dispatch op-disclosure-error with status: 401 for each event and log one console warning per page, so listen for that event if you monitor your pages.

GET /v1/disclosures/events (scope disclosure:read) returns events in ascending sequence, each exactly as it was hashed, plus the current chain_head. Filter with kind, jurisdiction, received_after and received_before, and page with cursor and limit (up to 1000).

Each event logged from 2026-10-19 on also records origin: the site the key’s allowed_origins verified for that request, or null when the key has no allowlist (no site was verified; the API never records an Origin header it did not check). The console’s Disclosures log shows it as Site, and the JSON Lines export carries it.

Each account has one append-only chain. The log is tamper-evident (hash-chained): changing, removing or reordering a stored event breaks every link after it. It is not signed.

  • sequence starts at 1 and increases by 1 per event, with no gaps.
  • prev_hash is the previous event’s hash; for sequence 1 it is sha256: followed by 64 zeros.
  • hash is sha256: plus the hex SHA-256 of the record without its hash key, serialised as canonical JSON: keys sorted, no whitespace, UTF-8, no floating-point numbers (RFC 8785 for these values).
  • The hashed record has exactly these keys: schema_version, event_id, account_id, sequence, prev_hash, kind, jurisdiction, policy_version, rule_id, subject_ref, content_ref, client_time, received_at, surface, locale and actor (type and id), and, in an op.disclosure-event.v2 record, origin. An absent optional value is null. Times are UTC, written YYYY-MM-DDTHH:MM:SS.ffffffZ.
  • Events logged before 2026-10-19 are op.disclosure-event.v1 records and have no origin key; later ones are op.disclosure-event.v2 and always have it (possibly null). One chain can hold both. Hash each record exactly as the API returns it, without adding or removing a key, and every link verifies.

To check an export yourself:

import hashlib
import json
GENESIS = "sha256:" + "0" * 64
def event_hash(record):
body = {key: value for key, value in record.items() if key != "hash"}
canonical = json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
return "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def first_broken_link(records):
"""Index of the first record that does not verify, or None."""
previous = None
for index, record in enumerate(records):
if record["hash"] != event_hash(record):
return index
if previous is None:
if record["sequence"] == 1 and record["prev_hash"] != GENESIS:
return index
elif (
record["sequence"] != previous["sequence"] + 1
or record["prev_hash"] != previous["hash"]
):
return index
previous = record
return None

This is the same function the server exposes (op_domain.disclosures.first_broken_link). Start from sequence 1 to check the whole chain, and compare the last hash with chain_head.

The chain is tamper-evident, not externally anchored. It detects a change to a stored event when you check it against an earlier export or against the receipts you were given, but Original Pictures holds the chain head, and nothing outside our systems fixes it in time. To be able to show later that an event existed with a given hash:

  • Keep every receipt (event_id, sequence, prev_hash, hash, received_at) on your side; the components hand each one to you in op-disclosure-logged.
  • Export the log regularly and keep the exports.

Anchoring the chain head with a KMS signature is planned. Until it ships, the log is not signed.