Disclosure SDK and evidence log
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.
The disclosure SDK
Section titled “The disclosure SDK”@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).
Jurisdictions and policies
Section titled “Jurisdictions and policies”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.
Record an event
Section titled “Record an event”POST /v1/disclosures/events (scope
disclosure:write, with an Idempotency-Key header) appends one event:
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.
From a browser
Section titled “From a browser”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.
Read and export the log
Section titled “Read and export the log”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.
Why the log is tamper-evident
Section titled “Why the log is tamper-evident”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.
sequencestarts at 1 and increases by 1 per event, with no gaps.prev_hashis the previous event’shash; for sequence 1 it issha256:followed by 64 zeros.hashissha256:plus the hex SHA-256 of the record without itshashkey, 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,localeandactor(typeandid), and, in anop.disclosure-event.v2record,origin. An absent optional value isnull. Times are UTC, writtenYYYY-MM-DDTHH:MM:SS.ffffffZ. - Events logged before 2026-10-19 are
op.disclosure-event.v1records and have nooriginkey; later ones areop.disclosure-event.v2and always have it (possiblynull). 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 hashlibimport 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 NoneThis 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.
What the log does not prove
Section titled “What the log does not prove”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 inop-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.