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 sync or async.
  • laidline evidence export packs a range into a bundle; its verify.py checks 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 failed in laidline_evidence_checkpoints_total and 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, sha the 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 marked entries, two pointer objects {"key": "<entry key>"}: index/asset/<asset_id>.json and index/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/), outside entries/ (Payload keys).
  • fp/<record_sha256>.bin: a keyed audio entry's fingerprint record (below, "Keyed audio entries"), put before the asset pointer with If-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:PutObject and s3:GetObject on <bucket>/*, plus s3:PutObjectRetention when retention_mode is set. The sink reads an object back after a 412, to compare it with the one it tried to write.
  • s3:ListBucket on the bucket. Exports and the registry list keys. Without ListBucket, 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:GetBucketObjectLockConfiguration on the bucket, with object_lock: true. The sink reads it at start. When this permission is missing, the AccessDenied surfaces 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 same asset_id in the asset store);
  • twin: null otherwise, with a warning when one was asked for: twin_skipped:too_long (longer than watermark.audio.max_sync_duration_s) or twin_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 marked entries.
  • Licensing: how credits are computed from the log.
  • Grading: how detect confirms a payload against an entry.
  • CLI: laidline evidence export, verify, check-slots and init-db.
  • Production: running a deployment.
  • AWS S3 and SQS: the S3 sink and its worker policy.