Evidence log
Read this if you run a deployment and need to know what Laidline records for every mark, where it stores it and how an auditor checks it. You will learn the entry schema, the sinks and their guarantees, checkpoints, the meter blocks and the export bundle. Skip if you only want to export and verify a bundle now (CLI). Time: 20 minutes to read, longer to apply. Needs: a configured deployment (Production).
In short
- Every mark attempt appends one JSON entry to a hash-chained stream; the first sink is authoritative.
- A checkpoint signs the chain head and anchors it with an RFC 3161 timestamp.
- File, S3 (Object Lock) and Postgres sinks keep entries append-only; replicas run
syncorasync. laidline evidence exportpacks a range into a bundle; itsverify.pychecks it without Laidline installed.- The evidence log is the meter: credits are computed from it when read.
Every mark attempt (image, audio, video or text completion) appends one JSON entry to a per-process stream.
Entries are canonicalised with JCS (RFC 8785) and chained: entry_hash = sha256(prev_hash || jcs(entry without entry_hash)), with prev_hash the previous entry's hex hash as ASCII and a genesis of 64 zeros. verify_stream()
recomputes a stream. At intervals a checkpoint signs the chain head and anchors it with an RFC 3161 timestamp.
laidline evidence export packs a range of the log into a bundle, and the verify.py inside it (or laidline evidence verify) checks that bundle without Laidline installed.
A stream is <hostname>-<6 hex> and belongs to one process, so there is one writer per stream and no locking across
workers. Each process restart starts a new stream at seq: 1. The server writes one marked entry per start with
route: startup:selftest (the readiness probe signs a 256 px test image).
Sinks
evidence.sinks[] (Configuration) lists where entries and checkpoints go. The first sink is authoritative: detect
reads it, and a failed write there fails the mark under both on_mark_failure policies. The others are best-effort
replicas. Every sink stores the same bytes for an entry: compact JSON (ensure_ascii=False), which is the file
sink's line without the newline, the S3 object body and the Postgres doc.
Replicas: sync or async
evidence.replicas (Configuration) decides how sinks[1:] are written; the primary is always written synchronously,
under the chain lock, before the mark returns.
| Mode | How replicas are written | What a replica holds | Failure visibility |
|---|---|---|---|
sync (default) |
In order after the primary, under the chain lock. | Every entry the primary holds, unless a write failed. | A failed write is logged, counted (laidline_evidence_sink_failures_total{sink="<type>:<index>"}) and skipped; the chain advances. |
async |
Each replica from its own FIFO queue (replica_queue_max) and writer thread; a failed write is retried twice (0.5 s, doubling). |
Lags the primary. Entries dropped on a full queue, or still queued after replica_drain_timeout_s at close(), never arrive. |
A full-queue drop is counted in laidline_evidence_sink_failures_total and laidline_evidence_replica_dropped_total and logged once per overflow episode; an entry not drained at close() is counted in laidline_evidence_sink_failures_total only. laidline_evidence_replica_queue_depth shows the backlog. A crash or SIGKILL before close() loses the queue without counting it. |
In both modes a verify of the replica reports its gaps, later rather than at mark time. With async, checkpoints
and the readers go straight to the replica, so a reader may briefly see a checkpoint before the entries it anchors
have landed there. Which to choose was measured (Benchmarks): in the bucket's region an S3 write costs p50
54.9 ms and p95 77.0 ms (Lambda in eu-central-1, Object Lock, n=200), under the 150 ms line, so
sync stays the default and the in-region recommendation; set async for an S3 replica in another region or behind a
long network path (p50 533 ms, p95 556 ms from a workstation to eu-central-1).
All S3 sinks of one process (and the S3 ingest store) share boto3's default session and so one set of credentials: an AWS primary with an R2 or SeaweedFS replica that needs other credentials is not possible in one process.
File
- Entries:
<path>/<YYYY-MM-DD>/<stream>.jsonl, one line per entry, flushed and fsynced per write. - Checkpoints:
<path>/checkpoints/<stream>/<to_seq:012d>.json, one file each, written to a temporary file, fsynced and hard-linked into place, so a checkpoint file is never replaced: when the name exists, identical bytes are success (a retry whose first write landed) and different bytes are a conflict (EntryConflict). - The directory must be on a filesystem that supports hard links. ext4, xfs, btrfs, Docker named volumes, NTFS
and APFS do; exFAT and FAT, s3fs-style FUSE mounts and some SMB mounts do not. On those every checkpoint write fails
(counted as
failedinlaidline_evidence_checkpoints_totaland retried with backoff) while entries keep landing, so the log grows without anchors. - Registry (
detect, ingest duplicate check): a linear scan of the directory. Fine for development and small logs, not for detect at scale. - Keyed audio fingerprint records (below, "Keyed audio entries"):
<path>/fp/<sha[:2]>/<sha>.bin,shathe record's SHA-256, written before the entry the same way as a checkpoint (temporary file, fsync, hard link), so a record is never replaced: identical bytes under the name are success, different bytes a conflict.
S3
One object per entry, the only shape that works under WORM, where objects cannot be appended to. Under prefix:
entries/<YYYY-MM-DD>/<stream>/<seq:012d>.json: the entry.checkpoints/<stream>/<to_seq:012d>.json: the checkpoint document.- For
markedentries, two pointer objects{"key": "<entry key>"}:index/asset/<asset_id>.jsonandindex/wm/<issuer_id:05d>/<asset_seq:015d>/<asset_id>.json(fixed width; the watermark pointer also carries the entry's 1,024-byte slot). The registry reads these (one GET, or one LIST, plus one GET per hit) and never scans the log. A keyed deployment also writes five decoy pointers at the end of the watermark index (index/wm/99999/...) and the five decoy entries they name (index/decoy/), outsideentries/(Payload keys). fp/<record_sha256>.bin: a keyed audio entry's fingerprint record (below, "Keyed audio entries"), put before the asset pointer withIf-None-Match: *. The key is the record's own hash, so a retried or adopted entry finds its record in place: equal stored bytes are success, different bytes under the hash a conflict, and a record is never re-put over another.
Write order: the asset pointer, then the entry object, which is the commit point, then the watermark pointer (so a watermark pointer never names an entry that did not land). If the asset pointer put fails, nothing
exists under entries/ yet and the chain's retry of that seq succeeds. A pointer left without its entry is harmless,
because the registry treats a missing entry as no hit; a watermark pointer that fails after its entry is logged, and
laidline evidence check-slots lists it. Every put carries If-None-Match: * (with
conditional_put, the default). A 412 whose stored bytes equal ours counts as success, since it means a retried put
whose first attempt landed. Different bytes are a conflict (see below). Writes are synchronous, under the chain lock,
with short client timeouts (connect 5 s, read 10 s, three attempts). A marked entry costs three sequential PUTs, which is why Postgres is the recommended primary for high volumes (Benchmarks has the measured latency).
With retention_mode and retention_days, every put sets its own retention. With only object_lock: true, the
bucket's default retention applies. The sink checks at start that the bucket has Object Lock enabled.
IAM for the worker (the EvidenceAppendOnly and EvidenceList statements of the worker policy in
AWS S3 and SQS):
s3:PutObjectands3:GetObjecton<bucket>/*, pluss3:PutObjectRetentionwhenretention_modeis set. The sink reads an object back after a 412, to compare it with the one it tried to write.s3:ListBucketon the bucket. Exports and the registry list keys. WithoutListBucket, S3 answers a GET of a missing key with 403 instead of 404, so a registry lookup of an unknown asset raises instead of returning no hit.s3:GetBucketObjectLockConfigurationon the bucket, withobject_lock: true. The sink reads it at start. When this permission is missing, theAccessDeniedsurfaces with the same wording as a store without an Object Lock API ("could not be read (AccessDenied); R2 has no S3 Object Lock API"), so on AWS check the policy first.- No
s3:DeleteObject*. The worker never deletes.
S3-compatible stores. Set endpoint_url. With any endpoint_url (not only R2) and object_lock: false, the
client sends request checksums only when an operation requires them, because several stores reject the default CRC
checksums. Object Lock puts need a checksum, so with object_lock: true the checksums stay on. Cloudflare R2 has no
S3 Object Lock API, and object_lock: true there fails at start with the message above, so an R2 sink runs with
object_lock: false. SeaweedFS 4.47 run locally creates
buckets with Object Lock, and the sink ran against it with object_lock: true and GOVERNANCE
retention. The lock semantics themselves (overwrite and delete refused) were tested on AWS only.
Postgres
One row per entry in <schema>.evidence_entries and one per checkpoint in <schema>.evidence_checkpoints. doc is
json (not jsonb), which keeps the exact text the file sink writes. The registry columns (asset_id,
wm_issuer_id, wm_asset_seq, fingerprint) are filled at insert. asset_id and (wm_issuer_id, wm_asset_seq) are
indexed, both partial on event = 'marked'; fingerprint is stored but not indexed. A marked row also stores its
fixed-size slot (keyed detect's padded lookup, Payload keys); a keyed audio marked row's fingerprint record (below,
"Keyed audio entries") is a row of <schema>.evidence_fp (stream, seq, record), inserted in the entry row's own
transaction, before it, so an entry row never exists without its record; five decoy rows live in the same table (stream
s-decoy-0000000000000000, issuer 2147483647, after every issuer in the slot index) and no reader of entries
(exports, verification) returns them. External SQL verifiers and DBAs must exclude exactly those five rows
(NOT (event = 'decoy' AND stream = 's-decoy-0000000000000000' AND wm_issuer_id = 2147483647)); any other decoy
row is an anomaly. A schema from an earlier development build holds four decoy rows under issuer -1 and is refused at
start in keyed mode: recreate it (there is no migration). The slot is not part of the
hash chain: laidline evidence check-slots checks every stored slot against its entry and every decoy row against the
canonical one (it also checks an S3 sink's watermark pointers and decoy objects). Operations: every marked row stores
about 1 kB more (keyed or not) and the slot index is about that size before compression; init-db builds its indexes
with CREATE INDEX (not CONCURRENTLY) in one transaction, which blocks inserts on a large table; auto_init
(development only) runs the migration under the write connection's 5 s statement_timeout. A store holding marked
rows (or S3 watermark pointers) without a slot is refused at start in keyed mode: nothing written without a slot can
be served by keyed detect. A duplicate
(stream, seq) is success when the stored doc is identical (a retry whose first attempt committed) and a conflict
otherwise. Writes go through one autocommit connection with a 5 s statement_timeout. Reads (exports, registry)
use a small pool with read_timeout_s.
Both timeouts (and TimeZone=UTC) are sent as the libpq startup parameter options. PgBouncer and RDS Proxy in
transaction pooling mode ignore startup options, so behind them neither timeout applies: set statement_timeout on
the app role instead (ALTER ROLE laidline_app SET statement_timeout = '5s'), or connect the sink directly or through
session pooling.
The DDL that laidline evidence init-db runs, in one transaction (schema laidline, app role laidline_app):
CREATE SCHEMA IF NOT EXISTS laidline; CREATE TABLE IF NOT EXISTS laidline.evidence_entries ( stream text NOT NULL, seq bigint NOT NULL, ts timestamptz NOT NULL, event text NOT NULL, asset_id text, wm_issuer_id integer, wm_asset_seq bigint, fingerprint char(16), entry_hash char(64) NOT NULL, prev_hash char(64) NOT NULL, doc json NOT NULL, PRIMARY KEY (stream, seq)); CREATE INDEX IF NOT EXISTS evidence_entries_asset_id ON laidline.evidence_entries (asset_id) WHERE event = 'marked'; CREATE INDEX IF NOT EXISTS evidence_entries_wm ON laidline.evidence_entries (wm_issuer_id, wm_asset_seq) WHERE event = 'marked'; CREATE INDEX IF NOT EXISTS evidence_entries_ts ON laidline.evidence_entries (ts); -- keyed detect's padded lookup (docs/keys.md "Equal work"): each marked row's 1,024-byte slot, the seek -- index covering it, and five decoy rows (stream s-decoy-0000000000000000, event 'decoy', issuer 2147483647, after -- every issuer) inserted once ALTER TABLE laidline.evidence_entries ADD COLUMN IF NOT EXISTS slot text; CREATE INDEX IF NOT EXISTS evidence_entries_wm_seek ON laidline.evidence_entries (wm_issuer_id, wm_asset_seq, ts) INCLUDE (event, slot) WHERE event IN ('marked', 'decoy'); DROP INDEX IF EXISTS laidline.evidence_entries_wm_slot; -- an earlier slot index (development schemas) CREATE INDEX IF NOT EXISTS evidence_entries_slotless ON laidline.evidence_entries (stream) WHERE event = 'marked' AND slot IS NULL; INSERT INTO laidline.evidence_entries (...) VALUES ('s-decoy-0000000000000000', 0, ...) ON CONFLICT (stream, seq) DO NOTHING; -- (and seq 1 to 4 the same way) CREATE TABLE IF NOT EXISTS laidline.evidence_checkpoints ( stream text NOT NULL, to_seq bigint NOT NULL, from_seq bigint NOT NULL, ts timestamptz NOT NULL, head_hash char(64) NOT NULL, doc json NOT NULL, PRIMARY KEY (stream, to_seq)); -- keyed audio fingerprint records: one fixed size, stored uncompressed CREATE TABLE IF NOT EXISTS laidline.evidence_fp ( stream text NOT NULL, seq bigint NOT NULL, record bytea NOT NULL, PRIMARY KEY (stream, seq), CHECK (octet_length(record) = 15469)); ALTER TABLE laidline.evidence_fp ALTER COLUMN record SET STORAGE EXTERNAL; -- the P + 1 decoy fingerprint records of keyed audio detect, once INSERT INTO laidline.evidence_fp (stream, seq, record) VALUES ('s-decoy-0000000000000000', 0, '\x...'::bytea) ON CONFLICT (stream, seq) DO NOTHING; -- and seq 1 to 4 CREATE OR REPLACE FUNCTION laidline.laidline_append_only() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'laidline evidence is append-only: % on %.%', TG_OP, TG_TABLE_SCHEMA, TG_TABLE_NAME; END $$; -- For each of evidence_entries, evidence_checkpoints and evidence_fp, each trigger created only when absent (a DO -- block checks pg_trigger), so re-running init-db, or an auto_init start, never drops or re-creates one: CREATE TRIGGER evidence_entries_append_only BEFORE UPDATE OR DELETE ON laidline.evidence_entries FOR EACH ROW EXECUTE FUNCTION laidline.laidline_append_only(); CREATE TRIGGER evidence_entries_no_truncate BEFORE TRUNCATE ON laidline.evidence_entries FOR EACH STATEMENT EXECUTE FUNCTION laidline.laidline_append_only(); -- Grants for the app role: REVOKE ALL ON TABLE laidline.evidence_entries, laidline.evidence_checkpoints, laidline.evidence_fp FROM PUBLIC, laidline_app; REVOKE CREATE ON SCHEMA laidline FROM PUBLIC, laidline_app; GRANT USAGE ON SCHEMA laidline TO laidline_app; GRANT SELECT, INSERT ON TABLE laidline.evidence_entries, laidline.evidence_checkpoints, laidline.evidence_fp TO laidline_app;
Two roles. A migrator owns the schema and runs init-db. The app role, which appears in the sink's DSN, can only
read and insert:
# As the DBA: two login roles; the migrator may create schemas in the database. psql -c "CREATE ROLE laidline_migrator LOGIN PASSWORD '...'" psql -c "CREATE ROLE laidline_app LOGIN PASSWORD '...'" psql -c "GRANT CREATE ON DATABASE laidline TO laidline_migrator" # As the migrator (idempotent). Defaults: --dsn-env LAIDLINE_PG_MIGRATOR_DSN --schema laidline --app-role laidline_app export LAIDLINE_PG_MIGRATOR_DSN='postgresql://laidline_migrator:...@db.internal:5432/laidline' laidline evidence init-db # The worker connects as the app role (evidence.sinks[].dsn_env, default LAIDLINE_PG_DSN). export LAIDLINE_PG_DSN='postgresql://laidline_app:...@db.internal:5432/laidline'
init-db refuses an app role that does not exist, that is the migrator itself, that is a superuser, or that is a
member of the migrator role, since any of these could bypass or drop the triggers. auto_init: true runs the same DDL
as the sink's own role, which then owns the tables. That is for development only. DSN parse errors are reported
without the DSN, so a password never reaches a log.
Append-only guarantees per sink
| Sink | What stops a rewrite | What remains possible |
|---|---|---|
| file | Nothing beyond file permissions. | Anyone with write access to the directory can edit or delete lines. The hash chain and the checkpoints make that evident, not impossible. |
| S3 without Object Lock | Conditional puts: the worker never overwrites a key. | A principal with s3:PutObject or s3:DeleteObject outside Laidline can replace or delete objects. The chain detects it. |
| S3 with Object Lock | Object versions cannot be overwritten or deleted before their retention ends. Under COMPLIANCE nobody can, the account root included. Under GOVERNANCE only a principal with s3:BypassGovernanceRetention can, using the bypass header. |
Object Lock blocks destruction, not hiding. A plain DeleteObject (no version id) adds a delete marker that hides the latest version from listings and GETs. The chain detects hiding: the missing seq breaks verification, and the original version stays retrievable by its version id. |
Postgres (init-db setup) |
The app role has SELECT, INSERT only. BEFORE UPDATE OR DELETE and BEFORE TRUNCATE triggers raise for every role, the owner included. |
The table owner (the migrator) or a superuser can disable or drop the triggers; the app role cannot. Then rows can be changed. The hash chain and the TSA-anchored checkpoints make that evident, not impossible. |
Hiding and rewriting are detected the same way in every sink. verify_stream and verify.py recompute the chain, and
a checkpoint whose TSA token predates the tampering shows which head hash existed at that time.
Entry schema v1
Common fields: v: 1, seq, ts (UTC, millisecond ISO 8601 with Z), stream, event, asset_id, modality
(image, audio, video or text), mime, system {name, version, model_id}, route, upstream, request_id,
tenant, extra, status, error, prev_hash, entry_hash.
event: marked, status: ok:
{"v":1,"seq":12,"ts":"2026-09-23T23:40:11.318Z","stream":"worker-7f3a1b","event":"marked", "asset_id":"ast_01M38DKK3NKTCJNYBF1NAPYDSD","modality":"image","mime":"image/png","bytes":184932, "sha256_out":"...","sha256_in":"...","fingerprint":{"alg":"phash64/v1","hex":"c3a1f0e4b2d19687"}, "system":{"name":"acme-flux","version":"2026.10","model_id":"black-forest-labs/FLUX.1-dev"}, "route":"ingest:gcs_pubsub","upstream":null,"request_id":"job-1","tenant":null,"extra":{}, "c2pa":{"manifest_id":"urn:c2pa:...","alg":"es256","key_id":"vault-transit:transit/laidline@v1", "signing_key":"default","cert_sha256":"...", "tsa":"http://timestamp.digicert.com","tsa_time":"2026-09-23T23:40:11+00:00","c2pa_rs":"0.90.19", "verify_after_sign":"ok","ingredient_manifest":null}, "watermark":{"scheme":"trustmark","variant":"Q","payload_hex":"...","issuer_id":4021,"asset_seq":123456789,"psnr_db":41.6}, "text":null,"warnings":[],"timings_ms":{"watermark":95,"fingerprint":3,"encode":40,"sign":230,"total":373}, "status":"ok","error":null,"prev_hash":"...","entry_hash":"..."}
c2pa.key_id is the signing backend's key reference: local, the AWS KMS key id or ARN, or
vault-transit:<mount>/<key_name>@v<version>. c2pa.signing_key is the id of the keyring key that signed
(signing.keys or signing.keys_file; default for the single-key configuration). A mark reads the active key once
and records key_id, signing_key and cert_sha256 from that same key, so a mark in flight during a key switch is
consistent. c2pa.tsa is null when the manifest carries no timestamp (tsa.required: false with no TSA answering,
or tsa.mode: checkpoint_only).
fingerprint is the phash64/v1 perceptual hash of the watermarked pixels, 16 hex digits
(laidline.media.fingerprint). detect uses it to confirm a watermark registry hit (Grading). The field is
additive to schema v1: entries written before it existed carry no fingerprint and confirm by exact bytes only.
Unmarked, failed and text entries carry fingerprint: null.
c2pa.ingredient_manifest is null for fresh outputs. When the input already carried a manifest (storage hook on a
provider's output), it holds the upstream manifest as read against this deployment's anchors:
{manifest_id, validation_state, trusted, issuer, claim_generator, failure_codes, laidline_asset_id}. The new manifest is
an EDIT with the original as a parentOf ingredient; nothing is stripped.
In keyed mode (Payload keys) an image entry's watermark block adds the payload layout fields beside scheme,
issuer_id and asset_seq: payload_layout (pixelseal-i77-r1 or trustmark-i61-r1), payload_layout_id (an
integer), key_id and key_fp (16 hex; never key material) of the active key that tagged it, tag_bits,
asset_seq at the layout's width (30 or 26 bits) and payload_hex (the full layout bits, tag included). The names
layout and payload_version are not used. Registry matching under keyed mode counts an entry only for the same
scheme, payload_layout and key_id; an entry without payload_layout is the CRC layout of its scheme (legacy). An
unkeyed entry is unchanged.
A keyed video entry carries the same fields with payload_layout videoseal-v92-r1 (asset_seq at 40
bits, payload_hex 23 hex digits; message_hex stays the 256-bit BCH message) or trustmark-kf61-r1 (26 bits, 16 hex
digits), and for the keyframe scheme variant (Q, the variant used), keyframes_skipped and skipped_positions (the
frame indices left unmarked under the PSNR floor).
watermark is null and warnings carries watermark_skipped:<reason> when the image was signed without a
watermark (too_small, psnr_below_floor).
event: unmarked (on_mark_failure: fail_open): status: unmarked, c2pa: null, watermark: null,
error: {reason, detail}, plus the size and hash of the unmarked bytes that were returned.
event: failed (fail_closed): status: error, error: {reason, detail}, no bytes.
event: config_changed with status: degraded is written once when a fail_open marker could not build its signer.
event: config_changed with status: ok records a signing-key switch (Certificates and keys, "Key rotation"):
{"v":1,"seq":88,"ts":"2026-10-01T00:00:00.412Z","stream":"sidecar-3c9d0e","event":"config_changed", "status":"ok","signing_key":{"from":"default","to":"k2026b","key_ref":"vault-transit:transit/laidline@v3", "cert_sha256":"...","reason":"schedule"}, "prev_hash":"...","entry_hash":"..."}
reason is schedule when a not_before or not_after boundary passed (written by the first mark or detect that
notices it) and reload when SIGHUP or POST /admin/reload brought in a different active key. A key counts as
different when its id, its leaf certificate (cert_sha256) or its key_ref (for Vault, the version) changed, so
re-certifying under the same id is recorded too. from is null after a degraded start. A reload that changes
nothing writes no entry. If the entry cannot be written the switch still holds; the failure is logged at ERROR and
reported by /admin/reload as "evidence": "failed:<reason>".
event: stream_rotated is seq 1 of a stream that replaced one which could not continue:
{previous_stream, previous_seq, reason} with reason: evidence_conflict (see below).
event: checkpoint records a checkpoint written for this stream (next section).
Checkpoints
A checkpoint is a signed, RFC 3161-timestamped statement of the chain head at the end of a range of entries. The
document (v: 1):
{"v":1,"type":"laidline.checkpoint","stream":"worker-7f3a1b","range":[1,1000],"count":1000, "first_ts":"2026-09-25T08:00:00.012Z","last_ts":"2026-09-25T08:04:59.870Z","ts":"2026-09-25T08:05:00.114Z", "head_hash":"<entry_hash at seq 1000>","prev_to_seq":null, "signer":{"alg":"es256","key_ref":"vault-transit:transit/laidline@v1","public_key_sha256":"...","cert_sha256":"..."}, "tsa":{"url":"http://timestamp.digicert.com","hash_alg":"sha256","message":"head_hash_ascii", "gen_time":"2026-09-25T08:05:00Z","serial":"4174...","response_b64":"MIIX..."}, "warnings":[],"signature":"<base64>"}
| Field | Meaning |
|---|---|
range |
[from, to] seq of the stream. |
count |
The seq span to - from + 1. It may include checkpoint events that fall inside the range, so it is not a count of assets. |
first_ts, last_ts, ts |
ts of the first and last entry in the range, and when the checkpoint was built. |
head_hash |
entry_hash of the entry at to. Through the chain it covers every earlier entry of the stream. |
prev_to_seq |
to of the previous checkpoint of this stream; null for the first one. |
signer |
The signing backend's alg, key_ref, SPKI SHA-256 and leaf certificate SHA-256: the same key that signs the manifests. null when the backend was unavailable (warning unsigned:signer_unavailable). |
tsa |
RFC 3161 token whose message is the 64 ASCII hex characters of head_hash (so the imprint is their SHA-256). serial is a decimal string, because 128-bit serials exceed the JCS integer range. null when no TSA is configured or none answered (warning tsa_missing:<reason>). |
meter |
The range's meter counts (see Meter blocks). Covered by the signature and the token like every field. |
signature |
Base64 of the raw P-1363 ES256 signature over jcs(document without "signature"); null when unsigned. |
With signing.tsa.roots_path set (the default, builtin, is the public DigiCert and Sectigo roots shipped in the
package, applied only while every configured TSA URL points at timestamp.digicert.com or timestamp.sectigo.com;
the server builds the Marker with the config's directory, so a relative path resolves from it), each token is
checked against those roots (the same check as verify.py --tsa-roots). The first token that
verifies is stored with verified_against_roots: true (additive); when no URL yields one, the first token that passed
the ordinary checks is stored with false, so a roots file never costs a checkpoint its timestamp. Roots that cannot
be used (unreadable, over 1 MiB, not PEM) also give false, with tokens accepted as before. With roots_path set to null the
token document is unchanged. Only verified tokens feed licence decisions (Licensing and metering).
Triggers. A checkpoint is built after every_n entries, when the oldest unanchored entry reaches every_seconds
(checked on every append and by the timer thread of long-running processes), and on Marker.close() with
on_close. checkpoint events never count towards a trigger. A range starts at the first other entry after the
previous range; the checkpoint event just after a range sits between two ranges, and the next head hash covers it
through the chain.
Writing. The TSA request, the signature and the sink writes run on one background worker, so an append only
updates counters. At most one checkpoint waits behind the one in flight (later triggers fold into it), so a slow TSA
cannot pile up work. The primary sink is written first. If that fails (or signing raises), the error is logged and
counted as failed, and the range is carried into the next checkpoint. The count and age triggers then back off (5 s,
doubling to 60 s, reset on success). Replicas are written best-effort. Finally a checkpoint event is appended to the
chain:
{"v":1,"seq":1001,"ts":"2026-09-25T08:05:00.702Z","stream":"worker-7f3a1b","event":"checkpoint", "checkpoint":{"range":[1,1000],"head_hash":"...","file":"checkpoints/worker-7f3a1b/000000001000.json", "signature_sha256":"<SHA-256 of the raw signature bytes>","tsa_gen_time":"2026-09-25T08:05:00Z", "status":"ok"}, "status":"ok","prev_hash":"...","entry_hash":"..."}
checkpoint.status is ok, tsa_missing (signed but no token) or unsigned; laidline_evidence_checkpoints_total
counts them, plus failed for checkpoints not written. file is the checkpoint's path relative to the sink root. The
file sink uses it as given, S3 puts it under prefix, and Postgres keys the row by (stream, to_seq) in
evidence_checkpoints.
A stream rotation first checkpoints the old stream's tail (best effort). The chain event for that checkpoint cannot
land on the stream that just conflicted, so it is missing by design; the new stream's checkpoints start again with
prev_to_seq: null.
One-shot commands. laidline mark closes its marker, which checkpoints the run's entries. With a TSA configured
but unreachable, that close waits up to about 12 s (two URLs, two attempts each, tsa.timeout_ms 3000) before
writing the checkpoint without a token.
tsa.mode: checkpoint_only. Manifests are signed without a per-asset timestamp and the checkpoints carry the
only RFC 3161 tokens. That saves a TSA round trip per asset (about 120 ms from the development machine), but detect
grades the C2PA signal B instead of A. A checkpoint token proves when the log reached a head hash, and so that every
entry up to it existed by then; it does not prove when a given manifest was signed, and C2PA validators never see it.
The export's summary.md says so under "Timestamps".
Meter blocks
Laidline meters marking from the evidence log itself (Licensing and metering). Units are never stored: they are computed when read, from facts the entries carry, under the signed weight table in force for the month.
Facts per asset entry (marked, unmarked, failed; every other event is not counted): the medium
(modality), the scheme (watermark.scheme, or text.scheme for text; signed_only when there is no watermark but a
C2PA manifest; none when there is neither; a text scheme label outside the text schemes Laidline knows (synthid, kgw) is other), the
outcome (read from the entry's event: marked, unmarked, failed), audio seconds (watermark.duration_s), video frames and seconds (watermark.frames / watermark.fps), text
tokens (text.completion_tokens), and from the entry meter block the image pixels, the environment and the
self-test flag. An audio or video entry without a watermark records no duration and is priced per mark only. Values
outside sane bounds (a duration over 10^9 s, a non-finite number, a count past 2^53 - 1) are read as unknown (0),
never as an error.
The entry meter block (written by asset entries that carry it; additive):
"meter": {"v": 1, "pixels": 1048576, "env": "production", "selftest": false}
pixels is the marked image's width times height (null for other media), env is LAIDLINE_ENV as set
(default development), and selftest is true only for the server's start-up self-test, set in-process (never from a
request header or body). Entries without the block (every entry written without it) count with env: unknown and
pixels null; the usage statement reports them as meter_missing:<n>. Self-test entries are not counted, but their
marked entries are shown as selftest_marks (the usage statement, laidline licence status, the monthly usage
report, the export's licence.months and verify.py). At most 10 per UTC day per deployment carry the flag; beyond
that a self-test mark is written as an ordinary, metered entry and its result warns self-test cap reached (10 per day); this mark is metered.
The checkpoint meter block sums the facts of the checkpoint's range per UTC month of the entries' ts:
"meter": {"v": 1, "range_counts": {"2027-03": {"image/pixelseal/marked/production": {"n": 980, "pixels": 1027604480, "seconds": 0.0, "frames": 0, "tokens": 0}, "text/synthid/unmarked/production": {"n": 20, "pixels": 0, "seconds": 0.0, "frames": 0, "tokens": 640}}}, "warnings": []}
The key is <medium>/<scheme>/<outcome>/<env>. A range that straddles a month end has two months; a failed checkpoint
whose range is carried into the next one keeps its counts (they are summed with the range they join). warnings may
carry facts_failed:<n> (entries whose facts could not be read: their counts are missing from this block, never the
range or the checkpoint, and a rebuild reads those entries instead) and clock_in_doubt:<month> (entries stamped more
than licence.clock_skew_s, default 300 s, before the latest TSA time verified against roots_path of a checkpoint
already written for an earlier range, or after this checkpoint's own verified time). The block is built in one guarded
step: if anything fails, including sums too large to sign, it is {"v": 1, "range_counts": {}, "warnings": ["facts_failed:all:<type>"]} and readers count those entries from the log instead; the meter can never stop a
checkpoint from being signed. verify.py checks the block as part of the checkpoint signature and otherwise ignores
it.
licence_state_changed records a change of the licence state, so the moment the cap was crossed would be in the
signed, timestamped chain. No timer appends it in this release: there is no state timer, and licence.status_interval_s
is parsed but not read. This is the entry's shape:
{"v":1,"seq":5012,"ts":"2027-03-21T14:02:11.507Z","stream":"worker-7f3a1b","event":"licence_state_changed", "status":"ok", "licence":{"from":"free_within_cap","to":"over_cap_unlicensed","month":"2027-03","units":1201.0,"cap":1200.0, "weights_version":"0.0.0-test","licence_id":null}, "prev_hash":"...","entry_hash":"..."}
(the numbers are test values). Nothing about the licence goes into an asset entry: a licence arriving later never makes past entries wrong.
Conflicts: adoption and rotation
The primary sink (Postgres, S3) refuses a second entry for the same (stream, seq). When a write lands but its
reply is lost, the chain does not advance and its next append reuses that seq. If the entry the primary holds there
is exactly the one the chain would have written next (same seq, prev_hash is the chain head, its hash verifies),
the chain adopts it, copies it to the replicas, logs the warning evidence_adopted_stored_entry, counts
laidline_evidence_adopted_total, and writes the new entry at the next seq. Nothing is lost and the stream stays
verifiable. An adopted entry may describe a request that already failed: the append whose reply was lost raised
evidence_unavailable to that request, so a marked entry adopted this way can belong to an asset that was never
returned marked. Under on_mark_failure: fail_open it is then followed by an unmarked entry for the same
asset_id (the bytes that were actually returned); read the later entry as the outcome.
Any other conflict (an entry that does not fit, or cannot be read) means the stream cannot continue. The marker then
rotates: the old stream's tail is checkpointed where possible and a new stream starts with a stream_rotated
entry naming the old stream and its last seq; the append is retried there once. A rotation is the recovery from an
unreadable conflict and stays visible in the log. The C2PA manifest of the asset being marked at that moment still
names the old stream; its entry is found by asset_id.
On S3 the asset pointer of a marked entry (index/asset/<asset_id>.json) is written before the entry, and the
watermark pointer (index/wm/...) after it so a retry of that asset on the new stream (or on
the next UTC day) finds an asset pointer naming the old entry key, which never landed. Entries and checkpoints are only ever written conditionally, but a pointer that names a different
key is re-put unconditionally: asset_id is unique to the process, so such a pointer can only come from an earlier
attempt for the same asset, and pointers are an index, not evidence. Under Object Lock this stores a new version of
the pointer; the old version stays.
A retried entry put gets a new ts, so a retry that crosses UTC midnight would land under the next day's key, where
no 412 reveals the first attempt. The S3 sink remembers the last failed entry put. When the same (stream, seq)
arrives on a different day, it first checks the old day's key and raises the conflict if that object exists.
Ingest entries
The storage hook writes route: ingest:<source> (s3_sqs, r2_queues, local; lambda_sqs on AWS Lambda,
gcs_pubsub on Cloud Run for both Eventarc and Pub/Sub pushes), upstream from
ingest.upstream_patterns, request_id: <bucket>/<key>, extra: {bucket, key, etag, attempts} and sha256_in of
the object as found in the bucket. Non-retryable problems (unsupported_mime, unsupported_format,
unsupported_input, undecodable_image, undecodable_audio, undecodable_video, video_too_long,
video_too_large, audio_too_long, audio_unavailable, video_unavailable, video_disabled, encode_failed) and each failed attempt at a retryable one (TSA, KMS)
produce a failed entry; storage errors before the download produce none.
Objects skipped as already marked or filtered produce no entry.
Proxy image entries
Images marked by the OpenAI-compatible proxy (OpenAI-compatible proxy) are ordinary image entries with
route: proxy:/v1/images/generations (or /edits, /variations), upstream set to upstream.name (default: the
host of upstream.base_url), request_id from X-Laidline-Request-Id or else the upstream's x-request-id,
tenant from X-Laidline-Tenant, and extra: {model, index} (the requested model and the item's position in
data; one entry per item). An edit or variation whose input carried a manifest has that manifest in
c2pa.ingredient_manifest.
Images marked by a generic adapter rule (Generic JSON adapter) have route: adapter:<rule name>,
upstream set to the rule's upstream name, request_id and tenant from the client's x-laidline-request-id and
x-laidline-tenant headers, and extra: {model, index} (model only when the request's JSON body has one).
Images marked by laidline-diffusers have route: diffusers:<PipelineClass> and extra: {index, model}, where
model is the pipeline's name_or_path.
Marks requested through MarkClient (for example by the ComfyUI node in remote mode) carry the caller's route and
upstream (X-Laidline-Route, X-Laidline-Upstream, at most 128 characters; longer values fall back to
route: http:/v1/mark/image).
Text entries
One entry per completion, written by the proxy for /v1/chat/completions and /v1/completions or by
laidline_transformers.generate_marked. The completion enters only as its SHA-256 (sha256_out) and UTF-8 byte
length (bytes); the prompt and the text never do.
{"v":1,"seq":40,"ts":"2026-09-24T18:02:11.904Z","stream":"sidecar-3c9d0e","event":"marked", "asset_id":"ast_01M3A2...","modality":"text","mime":"text/plain","bytes":1834,"sha256_out":"...","sha256_in":null, "fingerprint":null, "system":{"name":"acme-chat","version":"1","model_id":null}, "route":"proxy:/v1/chat/completions","upstream":"vllm","request_id":null,"tenant":null, "extra":{"model":"Qwen/Qwen2.5-0.5B-Instruct","stream":"0"}, "c2pa":null,"watermark":null, "text":{"scheme":"synthid","key_id":"k1","key_fingerprint":"bd58623f","model":"Qwen/Qwen2.5-0.5B-Instruct", "prompt_tokens":31,"completion_tokens":400,"finish_reason":"length","temperature":1.0, "watermark_applicable":true,"watermark_effective":true,"upstream_ack":true}, "warnings":[],"status":"ok","error":null,"prev_hash":"...","entry_hash":"..."}
text field |
Meaning |
|---|---|
scheme, key_id, key_fingerprint |
The scheme and key asked of the plugin (the proxy's injected values) and the first 8 hex digits of a SHA-256 over the derived key, never the key. |
model |
The request's model. |
prompt_tokens, completion_tokens |
From the upstream's usage (for streams, the usage chunk the proxy requests). |
finish_reason |
Last non-null finish_reason. |
temperature |
The request's temperature; 1.0 when absent (OpenAI's default); null when not a number. |
watermark_applicable |
completion_tokens >= watermark.text.min_tokens. |
watermark_effective |
The scheme can mark at this temperature: false for SynthID below temperature 1e-5 (vLLM's greedy threshold). |
upstream_ack |
The upstream answered x-laidline-watermarked: 1 with a scheme other than unknown. |
Status and reason: event: marked, status: ok when text marking is on, the completion is long enough, the scheme
is effective and the upstream acknowledged the plugin. Otherwise event: unmarked, status: unmarked, and
error: {reason, detail} with the first reason that applies, in this order: below_min_tokens, greedy_decoding,
plugin_missing. With watermark.text.require_plugin a missing ack is instead refused with a 503
and logged as event: failed, status: error, error.reason: plugin_missing, modality: text.
extra holds model and stream ("1" or "0"), plus usage: "unavailable" when the upstream reported no token
counts (the entry then has completion_tokens: 0, so the reason is below_min_tokens) and incomplete: "1" when a
stream ended before data: [DONE] (client disconnect, upstream EOF or read error), and sse: "truncated" when an SSE
line outgrew 1 MiB and was left out of the accounting (its bytes are still relayed). A streamed entry is written when
the stream ends, under the asset_id the response announced in x-laidline-asset-id.
Audio entries
Audio marks (Audio) are ordinary marked entries with modality: audio, fingerprint: null
and an audio watermark block. timings_ms has watermark, encode, sign and total.
{"v":1,"seq":57,"ts":"2026-09-27T09:12:40.118Z","stream":"sidecar-3c9d0e","event":"marked", "asset_id":"ast_01M3G8...","modality":"audio","mime":"audio/mpeg","bytes":103958,"sha256_out":"...","sha256_in":null, "fingerprint":null,"system":{"name":"acme-voice","version":"1","model_id":null}, "route":"proxy:/v1/audio/speech","upstream":"openai","request_id":"req_...","tenant":null, "extra":{"model":"gpt-4o-mini-tts","voice":"...","response_format":"mp3","mode":"buffered"}, "c2pa":{"manifest_id":"urn:c2pa:...","alg":"es256","key_id":"local","signing_key":"default","...":"..."}, "watermark":{"scheme":"audioseal","payload_hex":"0fb5","issuer_id":4021,"snr_db":"...","duration_s":4.45, "sample_rate":24000,"channels":1,"streaming":false}, "warnings":[],"timings_ms":{"watermark":943,"encode":"...","sign":"...","total":988}, "status":"ok","error":null,"prev_hash":"...","entry_hash":"..."}
watermark field |
Meaning |
|---|---|
scheme |
audioseal. |
payload_hex, issuer_id |
The 16-bit payload: the issuer id and nothing else, so the watermark cannot name this entry; only the C2PA manifest's asset_id resolves it. |
snr_db |
Signal-to-noise ratio of the marked audio against the input; null for streams. |
duration_s, sample_rate, channels |
Of the marked audio. |
streaming |
false for a whole-file mark, true for a streaming session. |
c2pa is null and warnings has manifest_skipped:container_unsupported for opus, aac and pcm output, and
watermark_payload_unverified:aac for aac. Audio shorter than watermark.audio.min_duration_s has watermark: null
and watermark_skipped:too_short. Audio failures add these error.reason values to failed entries:
audio_too_long (over watermark.audio.max_sync_duration_s), watermark_failed (a torch or AudioSeal error inside
the embed) and weights_unavailable (the weights could not be downloaded).
Streaming sessions (the streaming speech proxy, laidline-pipecat, laidline-livekit, Marker.audio_stream)
write one entry when the stream closes: watermark.streaming: true, snr_db: null, c2pa: null, sha256_in: null,
the warning manifest_skipped:streaming, timings_ms: {total} (the session's lifetime) and sha256_out, bytes and
mime of what was actually sent on (for the proxy, the encoded response; otherwise the marked pcm16 by default). A
stream that marked no audio writes no entry. A session below 16 kHz marks nothing: watermark: null and the warning
watermark_skipped:rate_below_16k. The entry has one more field, twin:
twin: {manifest_id, sha256, mime}when a signed twin was made (the speech proxy stores it as an mp3 under the sameasset_idin the asset store);twin: nullotherwise, with a warning when one was asked for:twin_skipped:too_long(longer thanwatermark.audio.max_sync_duration_s) ortwin_failed:<reason>.
Speech proxy entries have route: proxy:/v1/audio/speech and extra: {model, voice, response_format, mode}, never
the input text (model and voice cut to 128 characters); a stream that did not run to its end (client disconnect,
upstream read error, marking failure, or watermark.audio.max_sync_duration_s reached) adds extra.incomplete: "1",
and its hash and size cover what was emitted. Plugin entries carry the route you pass
(defaults pipecat:tts and livekit:tts_node) and, for Pipecat, the TTS context_id under extra.context_id.
Keyed audio entries
In keyed mode (Payload keys, "Audio") a marked audio entry carries the audioseal-p70-r1 payload and a fingerprint
commitment instead of fingerprint: null:
"fingerprint":{"alg":"hk32/v1","record_sha256":"...","record_bytes":15469,"duration_s":"000012.500"}, "watermark":{"scheme":"audioseal","payload_layout":"audioseal-p70-r1","payload_layout_id":49,"key_id":"k1", "key_fp":"...","tag_bits":24,"issuer_id":4021,"asset_seq":"...","payload_hex":"...", "layout":"audioseal-p2-c8x16-s10-v1","c0":5,"alpha":1.41,"pad_samples":0, "payload_readable_from_s":8.0,"snr_db":"...","duration_s":12.5,"sample_rate":24000,"channels":1, "streaming":false}
| Field | Meaning |
|---|---|
payload_layout … payload_hex |
The keyed payload as written: layout, key id and its fingerprint (never the key), tag width, issuer, asset sequence, the 70 bits in hex. |
layout, c0 |
The cell layout and the asset's random starting cell in the 8 s cycle. |
alpha |
1.41, set by Laidline for keyed audio. |
pad_samples |
Frames of silence put before the encoded audio so the mark stays on its 20 ms grid: keyed aac (ADTS) files, and keyed mp3 streams from the speech proxy. 0 otherwise. |
payload_readable_from_s |
8.0: the payload reads from this much delivered audio, on a 320-sample boundary (10 s through the stream marker, 0.5 s held back at close; presence from 1.5 s). |
stream_arm |
Streams only: the buffered embedder's arm, L2000-R200-H500. |
fingerprint |
The SHA-256 commitment to the fingerprint record; duration_s is a fixed-width string, so every keyed audio entry's field has one length. |
The fingerprint record. It is the hk32/v1 fingerprint of the first 120 s of the marked audio (for a stream, of
the audio that left): 3,750 bit-frames as little-endian uint32, then their validity mask bit-packed (little bit
order), each padded with invalid frames. That is always 15,469 bytes, whatever the audio's length. It is not part of
the chained entry; the entry commits to it by record_sha256, and each sink stores it beside the entry and before it:
the file sink at fp/<sha[:2]>/<sha>.bin, S3 at fp/<record_sha256>.bin, Postgres in evidence_fp in the entry
row's transaction. The file and S3 sinks address a record by its own hash and write it once; Postgres stores it
under the entry's (stream, seq), inserted once with the entry. Pairing: the record's
120 s is the default watermark.audio.max_sync_duration_s; a keyed configuration with a larger value is refused at
start, so no keyed audio mark is longer than its record.
How detect reads it. Every keyed audio detect with a chosen payload reads one record per registry slot
(four), the record the slot names or a stored decoy record, compares each record's SHA-256 with the slot's and the
fingerprint with the query (Payload keys, "Equal work"; Grading). For that, the stored slot (the Postgres slot
column, the S3 watermark pointer) carries the record's SHA-256 (record_sha256, 64 zeros for any other entry); a
store written before that field is refused at a keyed start (recreate it). Each store also holds P + 1 decoy records
of the same size: Postgres rows ('s-decoy-0000000000000000', 0..4) in evidence_fp (the migration inserts them),
S3 objects fp/<sha>.bin written with the other decoy objects at a keyed start, file-sink files
fp/<sha[:2]>/<sha>.bin written at a keyed start. A keyed start refuses a store without them; it checks that they
exist (S3: one HEAD each), and their bytes are compared by the audit, check-slots, not at start (a record of the
right size but other bytes changes no work: it never confirms). A Postgres store holding keyed audio rows but no
record beside them is refused at the start of a deployment that marks keyed audio; that check scans the marked rows of
a store with no record, so other deployments skip it.
Checking the records. laidline evidence check-slots also checks every keyed audio entry's record: present, of
the committed length and hashing to its commitment; every decoy record byte for byte; and lists every record no entry
names as an orphan (a record whose entry write failed, which nothing reads). --records-sample N checks N entries
(seeded) instead of all and lists no orphans, for large stores. One line per finding, exit 1 when there is one.
Video entries
Video marks (Video) have modality: video, mime: video/mp4, a fingerprint (the
phash64/v1 of the marked frame at 1 s, or at half the video when it is shorter) and a video watermark block.
timings_ms has probe, watermark (the embed and the H.264 encode of the video stream, which run interleaved chunk
by chunk), mux (extracting and AudioSeal-marking the audio track, writing the mp4), sign and total. Marks through
the job path have route: http:/v1/mark/video unless the upload set X-Laidline-Route, and request_id from
X-Laidline-Request-Id, else the job id.
"watermark":{"scheme":"videoseal","model":"videoseal_1.0","payload_hex":"...","message_hex":"<64 hex digits>", "issuer_id":4021,"asset_seq":123456789012,"psnr_db":51.1,"frames":120,"fps":24.0,"width":1280, "height":720,"rotation":0,"device":"cuda","chunk_size":32,"step_size":4,"audio_track":"marked"}
watermark field |
Meaning |
|---|---|
payload_hex, issuer_id, asset_seq |
The 92-bit payload: version, issuer id, a 48-bit asset_seq (the top 48 bits of sha256(asset_id)) and a CRC-16. detect looks entries up by (issuer_id, asset_seq) among videoseal entries. |
message_hex |
The 256-bit message actually embedded: the payload's BCH codeword, zero-padded. The manifest's soft binding carries the same value. |
psnr_db |
Mean PSNR of the marked frames against the source (4 frames per chunk). |
frames, fps, width, height |
Of the video stream; width and height are the displayed (upright) size. |
rotation |
The source's display rotation in degrees (0, 90, 180 or 270). Laidline marks the frames upright and writes the output upright with no rotation tag. |
device |
Where VideoSeal ran (cpu, cuda, cuda:0, ...), as watermark.device resolved it. |
audio_track |
marked (AudioSeal), copied (with the warning audio_track_unmarked:<reason> when marking was attempted) or none. |
With scheme: trustmark_keyframes the block is {scheme, variant, payload_hex, issuer_id, asset_seq, keyframes, keyframes_marked, keyframe_interval_s, psnr_db, frames, fps, width, height, rotation, device, audio_track}: the image payload
(36-bit asset_seq) on keyframes_marked of keyframes forced IDR frames. When TrustMark skips every keyframe the
entry has watermark: null and watermark_skipped:keyframes. A failed video mark is a failed entry under both
on_mark_failure policies (there is no unmarked fallback for video).
Export bundle
# --sink N: another sink laidline evidence export --out evidence-2026-09.zip --from 2026-09-01 --to 2026-09-30
--from and --to are inclusive and take a day (YYYY-MM-DD) or an ISO 8601 timestamp with Z or an offset;
without them the whole log is exported. On a running sidecar the same export is available over HTTP when the admin
routes are enabled (Configuration, admin):
curl -fH "Authorization: Bearer $LAIDLINE_ADMIN_TOKEN" -o evidence.zip \ 'http://127.0.0.1:8080/admin/evidence/export?from=2026-09-01&to=2026-09-30' # &sink=N: another sink
from and to are required there and may span at most admin.export_max_days; the response carries
x-laidline-bundle-sha256. &tenant=T (CLI --tenant T) narrows the counts to one tenant (Licensing and metering: What is free, what is metered, what is locked). Errors: 400 invalid_range or range_too_large, 402 licence_required (a tenant filter without tenant_reports), 409 export_in_progress (one export per
process at a time), 413 bundle_too_large (over admin.export_max_bytes), 500 export_failed. GET /admin/evidence/status returns the stream, seq and head, sink_failures per <type>:<index>, replica_queue_depth
(async replicas), the last checkpoint and the active signing_key.
Each stream's slice runs from its first entry in range to its last and is then
extended to the first checkpoint at or past that entry, so the slice ends on a signed, timestamped head. The summary
says when a stream was extended. Export reads one sink (the authoritative one by default), never builds a Marker, so
no watermark model loads, and holds the slice in memory. It prints a JSON summary: out, streams, entries,
checkpoints, unanchored, signed, records (included, count, missing) and warnings.
Fingerprint records. The bundle carries every exported keyed audio entry's fingerprint record
(fp/<record_sha256>.bin, 15,469 bytes each, one file per distinct record) by default, so a bundle shows not only that
an entry committed to a fingerprint but that the fingerprint is in hand. --without-records leaves them out (the
admin route always includes them, so its bundles grow by 15,469 bytes per keyed audio mark in the range and meet
admin.export_max_bytes sooner (413 bundle_too_large); export a large range with the CLI). A record the sink cannot
produce is exported as the warning
fp_record_missing:<stream>:<seq> and listed in the manifest's records.missing. Size: 100,000 keyed audio marks
are about 1.5 GB of records, so a large bundle needs verify.py --max-bytes raised past its 4 GiB default.
Layout (laidline.evidence-bundle/1):
| Path | Content |
|---|---|
manifest.json |
schema, generated_at, laidline_version, system, the requested range, source_sink, per stream {stream, file, first_seq, last_seq, count, extended_to_checkpoint, anchored_to, unanchored}, the checkpoint paths, every other file's SHA-256 (files), signer (alg, key_ref, public_key_sha256, cert_sha256, or null), signing_keys (per configured key: id, cert_sha256, not_before, not_after), records (included, count, bytes, missing: the <stream>:<seq> of each record the sink could not produce) and warnings. |
manifest.sig |
Base64 P-1363 ES256 signature over the raw bytes of manifest.json, by the key active at export time. Absent when the bundle is unsigned. |
streams/<stream>.jsonl |
The entries, one line each, exactly as the file sink writes them. |
fp/<record_sha256>.bin |
The fingerprint record each keyed audio entry commits to: its name, its SHA-256 in files and the entry's fingerprint.record_sha256 are one value. Absent with --without-records. |
checkpoints/<stream>/<to_seq:012d>.json |
The checkpoints whose to falls inside the slice. |
certs/<key id>.pem |
Every configured key's chain, retired keys included, so checkpoints signed before a rotation verify (certs/_<id>.pem for an id that would collide with the names below). |
certs/signing-chain.pem, certs/own-anchors.pem |
The active key's chain and the own trust anchors. |
certs/tsa-roots.pem |
The TSA roots in use: the packaged public DigiCert and Sectigo roots under the default signing.tsa.roots_path: builtin, or your file; absent when it is null, and absent under builtin when a configured TSA URL points at another host (the roots then apply to nothing), and absent when no TSA URL is configured. They follow the current config, so checkpoints timestamped earlier by a different TSA verify only with --tsa-roots pointing at that TSA's roots. |
summary.md |
Counts by event, by modality and status, and by failure reason; per stream the entries, checkpoints, Anchored (entries other than checkpoint events at or before the stream's last checkpoint in the bundle) and Unanchored counts; the marking-method text under ## Marking method (Measures 1.1 and 4.1) and the detection-access text under ## Detection access (Measures 2.1 and 3.4) (EU Code of Practice numbering of 10 June 2026); a "Timestamps" note under checkpoint_only; export warnings. |
compliance/licence-clause.md |
The licence clause (Compliance compliance paperwork). |
verify.py, README.txt |
The standalone verifier and how to run it. |
The bundle is unsigned, with a warning, when no signing certificate could be loaded
(unsigned:no_signing_certificate), when the signing backend cannot be reached or signing fails (Vault or KMS down,
for example; unsigned:<ExceptionType>), or when its key does not match the certificate
(unsigned:signing_key_does_not_match_certificate). The export never fails for that reason. When the keyring or the
own anchors cannot be loaded the warning is certs_unavailable:<ExceptionType>. These warnings name the exception
type only, never its message, because the bundle goes to third parties and messages can carry local paths, host names
or backend detail; the export's log line keeps the message for the operator. One key's chain file that cannot be read
is skipped with cert_chain_unavailable:<id>:<ExceptionType>, and an unreadable signing.tsa.roots_path gives
tsa_roots_unavailable:<ExceptionType>; like the others, these two name the exception type only (for example
OSError, FileNotFoundError or ConfigError), and the log line keeps the message. The zip is deterministic
(sorted names, fixed timestamps), but two exports of the same range still differ: generated_at changes, and
manifest.sig differs even over identical bytes, because ECDSA signatures are randomised. Compare the files that
manifest.json hashes, not the zips.
verify.py
# the zip or its unpacked directory python3 verify.py evidence-2026-09.zip # machine-readable report (laidline.verify/1) python3 verify.py evidence-2026-09.zip --json # a check that could not run fails the run python3 verify.py evidence-2026-09.zip --strict python3 verify.py evidence-2026-09.zip --tsa-roots roots.pem # read up to 16 GiB (default 4 GiB) python3 verify.py evidence-2026-09.zip --max-bytes 17179869184 # the same code, from the installed package laidline evidence verify evidence-2026-09.zip
Exit codes: 0 verified, 1 integrity failure (or, with --strict, a skipped check), 2 unreadable input. A bundle
whose files add up to more than --max-bytes uncompressed (default 4 GiB) is unreadable input (bundle_too_large,
exit 2); the cap is enforced while reading, so a zip that understates its sizes cannot get past it. laidline evidence verify applies the 4 GiB default and has no --max-bytes option; use verify.py for a larger bundle.
| Needs | Checks |
|---|---|
| Python 3.10 or later, standard library only | Every file against its SHA-256 in manifest.json; each stream's hash chain (entry hashes, links, contiguous seq, the genesis link at seq 1); each checkpoint's head_hash against the entry at to; every checkpoint event against the checkpoint file it names; every keyed audio entry's fingerprint record (below). |
pip install cryptography |
ES256 signatures: manifest.sig and every checkpoint's signature, against the certificate whose leaf the signer's cert_sha256 names, searched across every chain in certs/ (certs/<key id>.pem and certs/signing-chain.pem, not the TSA roots), so checkpoints signed by a key rotated out since still verify. Bundles exported before key rotation existed (one chain) verify as before. |
pip install rfc3161-client |
The RFC 3161 tokens: imprint and genTime, and with TSA roots also the token's signature and certificate chain. |
A check whose package is missing is reported as skipped:no_cryptography or skipped:no_rfc3161_client and fails
the run only under --strict; a signature whose key is not among the bundle's certificates is
skipped:no_matching_cert. skipped: reasons describe what the verifier could not do. What a document claims is
checked, never skipped: a signer.alg other than es256 (the only algorithm the bundle schema defines) is bad, so a
bundle cannot declare its way out of a signature check. TSA roots come from --tsa-roots or, failing that, the
bundle's certs/tsa-roots.pem. Without either, a token whose imprint matches is reported imprint_ok_unanchored.
A checkpoint anchors its stream up to to (anchored_to) only when its head matches, none of its checks is bad
or imprint_mismatch, and at least one check actually ran and passed: the signature is ok, or the token is ok or
imprint_ok_unanchored. A skipped check never anchors. Without cryptography and without a token (or without
rfc3161-client), a checkpoint anchors nothing, the tail is reported unanchored, and the warning
checkpoint_not_anchoring:<stream>:<to>:<skip reason> says why; install the packages to anchor it.
Trust: the signing certificate and any TSA roots travel inside the bundle, so on their own they only show that the
bundle is internally consistent. Compare signer.public_key_sha256 in manifest.json with the key you expect from
the provider, and pass --tsa-roots from an independent source (the TSA's published roots) to anchor the timestamps.
Fingerprint records. For every marked entry whose fingerprint is an object with alg hk32/v1:
fp_record_field:<stream>:<seq> (error) when record_sha256 is not 64 hex digits or record_bytes not an integer;
fp_record_missing (error) when fp/<record_sha256>.bin is not in the bundle; fp_record_size (error) when it is
not record_bytes long; fp_record_mismatch (error) when its SHA-256 is not its name (the manifest check reports
file_hash_mismatch too); fp_record_unreferenced:<name> (warning) for a file under fp/ no entry names. Two entries
naming one record share one file. A bundle exported --without-records reports records:skipped:records_omitted
once, which fails the run only under --strict. A manifest without a records block is checked as one that includes
the records (no bundle predates them), so its missing records are errors. Each stream in the report carries records: {referenced, present, missing, mismatched}, and the rendered report prints one line per stream that references records. The store-side
check is laidline evidence check-slots ("Keyed audio entries").
A stream whose slice begins after seq 1 is reported with starts_mid_stream: true, and its first prev_hash is
taken as given. Warnings do not fail a run: unanchored_tail:<stream>:<n> counts entries after the last checkpoint
in the bundle, and bundle_unsigned, checkpoint_unsigned and checkpoint_tsa_absent report missing signatures or
tokens. A date-range slice that begins on a checkpoint event can also report checkpoint_file_missing for the
checkpoint that event names, which ends just before the slice and so is not in the bundle.
Licence attestation. A licensed deployment's bundle carries the licence that unlocks each exported month
(licence/licence.json for one, licence/<licence_id>.json each for several), licence/laidline-roots.json and,
once issuer.certificate_path is allowed, licence/issuer-certificate.json. verify.py checks each licence's Ed25519 signature under the
Laidline roots pinned in the script (never the bundle's own roots file), that it binds a key that verifiably signed the
bundle (the manifest signer only when manifest.sig verified, a checkpoint signer only when that checkpoint's
signature verified and its head matches, an organisation CA only on the verified certificate path upward from such a
signer), and which asset entries the licences unlock: an entry is licensed when a licence unlocks its month and binds
the manifest signer or the signer of a checkpoint whose range holds it; a month is covered when all its entries are. The report's licence block (status, licence_id,
licence_ids, detail, covered_months, uncovered_months, present_not_covered) and the text lines say
licence: verified (covers <months>) when every month inside the requested range is covered and at least one is, not_covering_range
with the months not covered, invalid (<reason>), absent, unsigned_pack (a licence in a bundle whose manifest is
not signed is never verified) or skipped:no_cryptography; months present outside the requested range and not
covered are named on a second line (entries of <months> are in the pack and are not covered by the licence). The
licence report never fails the run: the evidence verifies on its own. It does not consult directory revocations. This
release pins the two production Laidline roots, so a licence in a bundle verifies when a production issuing key
signed it.
An export over the free cap without a licence is never refused for the cap: it carries an UNLICENSED USE notice
instead of the compliance texts (withhold_paperwork; a lock_mode of refuse_export reads the same); see
Licensing and metering. A licence without the paperwork feature withholds the paperwork of its over-cap months
the same way, and the only refusal is a tenant filter without tenant_reports (exit 3 / HTTP 402). The decision covers
the months of the asset entries inside the requested range; entries carried after the range to reach the anchoring
checkpoint decide nothing when they fall after the month of --to and their stream carries at most 1,000 of them; a
range with no asset entry inside it is decided by every asset entry the bundle carries. With the notice, manifest.json has a licence block (state, months_over_cap,
units, cap, weights_version per month, decided_months); a licensed bundle's manifest names licence_id,
covered_months, decided_months, licences (each licence with the months it unlocks), weights_version per
decided month (the version of the weight table that priced it: the licence's own table when it carries one) and, when
usage passed a licence's grant, over_grant (the paperwork renders, with a line in summary.md).
Metrics
laidline serve exposes these on /metrics (Prometheus; the server extra). The sink label is <type>:<index in evidence.sinks>, never a bucket, path or DSN.
| Metric | Type | Meaning |
|---|---|---|
laidline_evidence_sink_failures_total{sink} |
counter | Failed replica writes, including async drops and entries not drained at close. |
laidline_evidence_checkpoints_total{status} |
counter | Checkpoints by outcome: ok, tsa_missing, unsigned (written) and failed (not written; the range is retried). |
laidline_evidence_hook_failures_total |
counter | Exceptions raised by the chain's append hook (the checkpoint trigger); the entry itself is stored, the error is logged. |
laidline_evidence_adopted_total |
counter | Stored primary entries adopted after a lost write reply (see "Conflicts"). |
laidline_evidence_replica_queue_depth{sink} |
gauge | async replicas: entries queued. |
laidline_evidence_replica_dropped_total{sink} |
counter | async replicas: entries dropped because the queue was full. |
Identifiers
asset_id is ULID-shaped (ast_ + 26 Crockford base32 characters). detect resolves a decoded payload back to
entries by (issuer_id, asset_seq), confirms each hit by exact bytes or fingerprint (Grading), and resolves the
exact entry by the asset_id in the manifest. The width of the two fields depends on the payload layout, and both
layouts exist:
| Layout | issuer_id |
asset_seq |
Resolves an entry |
|---|---|---|---|
| Unkeyed image payload (61 bits, TrustMark) | 22 bits | 36 bits, the top bits of sha256(asset_id) |
yes |
| Unkeyed VideoSeal payload | 24 bits | 48 bits, the top 48 bits of the same hash | yes |
| Unkeyed AudioSeal payload | 16 bits | none | no: an audio watermark never resolves an entry |
| Keyed layouts (Payload keys) | 16 bits | 30 (PixelSeal), 26 (TrustMark), 40 (VideoSeal) or 28 (AudioSeal) bits | yes |
The issuer number you configure is the same everywhere: 1 to 65,535 (Issuer numbers
lists the ranges). It fits every field in the table; the wider unkeyed fields only leave room. The keyframe scheme
uses the image payload. Async video jobs have ids job_ + 26 Crockford base32 characters; they are not evidence
entries, and the entry of a job's mark has the job id as request_id unless the upload named one.
Privacy
Prompts and user identifiers are never logged. The include_prompt_hash option (salted prompt hashes) is reserved
and not implemented yet. MarkContext.extra, tenant and request_id are evidence-only and never enter a manifest.
Reference
- Payload keys: the keyed layouts and the tag fields recorded in
markedentries. - Licensing: how credits are computed from the log.
- Grading: how detect confirms a payload against an entry.
- CLI:
laidline evidence export,verify,check-slotsandinit-db. - Production: running a deployment.
- AWS S3 and SQS: the S3 sink and its worker policy.