Configuration

Read this if you are setting up or changing laidline.yaml. You will find every section and key, its default and what it changes. Skip if you only want a first marked file (Quickstart) or a production checklist (Production). Time: look up a key in a minute. Needs: Laidline installed; some keys need an extra (image, audio, video, text, server, kms, evidence).

In short

  • One YAML file plus LAIDLINE_<SECTION>__<KEY> environment overrides; environment beats the file, the file beats defaults.
  • laidline init writes a starter file; the sections it leaves out take their defaults. An unknown top-level section stops the load.
  • An unset LAIDLINE_ENV means production: every relaxed check needs LAIDLINE_ENV=development exactly.
  • laidline serve binds 127.0.0.1; server.max_upload_bytes (64 MiB) caps every request body and server.mark_token_env adds an inbound token.
  • Licence and issuer settings never stop marking: every outcome is a reported state.

Laidline reads one YAML file plus environment overrides. Lookup order: an explicit path (Marker.from_config(path), --config), then LAIDLINE_CONFIG, then ./laidline.yaml, then /etc/laidline/config.yaml. Relative paths inside the file resolve against the file's directory. laidline init writes a starter file; the sections it leaves out (server, media, licence, ingest, upstream, assets and adapter) take their defaults.

The top-level sections are system, signing, watermark, evidence, on_mark_failure, detect, ingest, upstream, assets, adapter, admin, jobs, licence, issuer, server and media. Any other top-level key stops the load with unknown top-level section(s): <names> (known: ...), so a typo such as evidnce: fails instead of silently falling back to defaults. A top-level cloud: key is ignored with a warning. Keys inside a section are not checked this strictly: an unknown nested key is ignored, except where a section says that a misspelt key is an error.

Any key can be overridden with LAIDLINE_<SECTION>__<KEY> (double underscore per nesting level), for example LAIDLINE_SIGNING__TSA__REQUIRED=false or LAIDLINE_SYSTEM__NAME=acme-flux. Environment beats the file; the file beats defaults. Two variables are read directly rather than through the model: LAIDLINE_CONFIG (file path) and LAIDLINE_ENV (see "Environment" below).

Environment

LAIDLINE_ENV has one rule: unset means production. Every relaxed check needs LAIDLINE_ENV=development exactly: an inline payload-key secret, a secret file readable by others, the published test secret and issuer.extra_roots_file are refused or ignored otherwise. The one setting keyed to the word production itself is the local PEM signing backend, which laidline init writes for a first run: it is refused only under an explicit LAIDLINE_ENV=production unless signing.local.allow_in_production is true. Set LAIDLINE_ENV=production on every production host so a leftover local key cannot sign there.

LAIDLINE_MODELS_MIRROR (default unset) is the https base URL of a Laidline mirror of the model weights, for example https://laidline.com/models (Laidline's own mirror; you can point it at your own copy instead). When it is set, Laidline fetches each missing AudioSeal, VideoSeal, PixelSeal and TrustMark Q decoder graph (trustmark/decoder_Q.fp32.onnx) file from <base>/<file> first and from the URL pinned in the package second. The graph's pinned URL is that same Laidline mirror, https://laidline.com/models/trustmark/decoder_Q.fp32.onnx; the other files are pinned to their upstream hosts. Every source is checked against the same pinned SHA-256 and size, so the mirror is a convenience and never a trust root. There is no configuration-file key for it. laidline models fetch prints the host each file came from.

system

Key Default Meaning
name required Public name of your AI system. Goes into the C2PA manifest (claim_generator_info, softwareAgent).
version required Version string of the system, alongside the name.
model_id null Model identifier for the evidence log only.
provider_legal_name null Legal entity for the evidence log and compliance pack. Never in a manifest.

signing

Key Default Meaning
backend local local (development; PEM key on disk), aws-kms, or vault-transit (HashiCorp Vault or OpenBao Transit, below).
local.key_path ./certs/dev-key.pem PKCS#8 EC P-256 private key.
local.allow_in_production false Required to use local when LAIDLINE_ENV=production.
aws.key_id "" KMS key ARN or id, key spec ECC_NIST_P256. The worker needs kms:Sign and kms:GetPublicKey.
aws.region eu-central-1 KMS region.
cert_chain_path ./certs/dev-chain.pem PEM chain, leaf first. Validated at start (see Certificates and keys).
tsa.url http://timestamp.digicert.com RFC 3161 timestamp authority, or null for none. Used for per-asset timestamps (through c2pa-rs) and for checkpoints (Laidline's own client).
tsa.fallbacks [http://timestamp.sectigo.com] Tried in order when the primary fails, for assets and checkpoints alike.
tsa.timeout_ms 3000 Per-request timeout of the checkpoint client, which tries every URL twice: with the defaults an unreachable TSA costs a checkpoint about 12 s. c2pa-rs keeps its own timeout for per-asset timestamps.
tsa.required true Per-asset timestamps only. When true, marking fails (tsa_unavailable) if no TSA answers. When false, marking still asks the TSA and, only if none answers, signs without a timestamp: the evidence entry records tsa: null and detect grades the manifest B. The mark result carries no warning. A checkpoint whose TSA does not answer is written without a token either way (warning tsa_missing:<reason>).
tsa.mode per_asset per_asset: every manifest carries an RFC 3161 timestamp. checkpoint_only: manifests are signed without one (no TSA round trip per asset; tsa.required no longer applies to assets) and only checkpoints are timestamped. The trade-off: detect grades the C2PA signal B instead of A, and a checkpoint token proves when the log reached a head hash (so that every entry up to it existed by then), not when a given manifest was signed; C2PA validators never see it. See Grading.
tsa.roots_path builtin The TSA root certificates. builtin (the default) is the public DigiCert Trusted Root G4 and Sectigo Public Time Stamping Root R46 that ship inside Laidline, which anchor the default tsa.url and tsa.fallbacks; a path to your own PEM bundle replaces them (a relative path is read from the directory of this file; write ./builtin for a file that is really called builtin); null or an empty value turns verification off. laidline evidence export copies the roots in use into the bundle as certs/tsa-roots.pem, and verify.py then checks the tokens' signatures and chains against them. The server checks every checkpoint token against the roots on arrival: the first verifying token is stored with verified_against_roots: true; when none verifies, the first well-formed token is still stored, with false; only verified TSA times feed licence decisions (Licensing and metering). With a TSA other than DigiCert or Sectigo, set this to that TSA's roots: builtin applies only when every URL in tsa.url and tsa.fallbacks is on timestamp.digicert.com or timestamp.sectigo.com; otherwise it loads and bundles no roots, the usage statement counts none as configured, and the server logs one warning at start.
keys [] A keyring inline in this file (below). Replaces the single-key fields above.
keys_file null Path of a keyring file owned by laidline keys rotate (below). Exclusive with keys. When set, the file must exist (the first keys rotate creates it), or loading the keys fails.

With neither keys nor keys_file, the single-key fields (backend, local, aws, vault, cert_chain_path) form a keyring of one key with id default, as before.

An explicit roots_path: null keeps verification of the TSA tokens off. If your laidline.yaml has that line, change it to roots_path: builtin, or delete it to take the default. A file that never sets the key already verifies the default TSAs' tokens.

signing.keys and signing.keys_file (key rotation)

A keyring lists several signing keys with validity windows; Laidline signs with the one active now and switches at the boundaries without a restart. The rotation runbook is in Certificates and keys.

signing:
  # created and updated by `laidline keys rotate`; relative to this file
  keys_file: ./keys.yaml
  tsa: { ... }                       # stays here; shared by every key
# keys.yaml as `laidline keys rotate` writes it (paths relative to this file, times in UTC)
version: 1
keys:
- id: default
  backend: aws-kms
  aws:
    key_id: arn:aws:kms:eu-central-1:111122223333:key/...
  cert_chain_path: certs/chain-2026.pem
  not_after: '2026-10-01T00:00:00Z'
- id: k2026b
  backend: vault-transit
  vault:
    addr: https://vault.internal:8200
    key_name: laidline
    key_version: 3
  cert_chain_path: certs/chain-2026b.pem
  not_before: '2026-09-30T00:00:00Z'
Key (per entry) Meaning
id [A-Za-z0-9][A-Za-z0-9._-]{0,63}, unique. Recorded in evidence as c2pa.signing_key and in config_changed entries; names the key's chain in export bundles (certs/<id>.pem).
backend, local, aws, vault, cert_chain_path As the single-key fields above, per key.
not_before / not_after Optional ISO 8601 times with a timezone (Z or +hh:mm); not_after must be later than not_before.

Selection at any moment: keys whose not_after has passed are retired and never used; among the rest, the active key is the one with the latest not_before that has passed (no not_before counts as the earliest), and a tie goes to the later entry in the list. The others are standby (started but superseded) or scheduled (not_before in the future). Every non-retired key is built at start and at each reload (KMS and Vault calls happen then, never on the mark path), and each chain is validated at max(now, not_before). Loading fails when no key is active; once every key has retired, marks fail with signer_unavailable; if a later key is scheduled, marks resume at its not_before without a reload (until then, timer checkpoints keep signing with the previous key).

A switch, scheduled or by reload, appends a config_changed entry (Evidence log). SIGHUP (laidline serve, laidline ingest) or POST /admin/reload re-reads the keys (the keys file, or the inline or single-key fields of the configuration loaded at start) and their chain files; nothing else in the configuration is reloaded. The keys file is locked with fcntl.flock on <keys_file>.lock, so keys rotate works on POSIX systems only.

signing.vault

Used by backend: vault-transit (hvac, in the kms extra). The key must be a Transit key of type ecdsa-p256; Laidline signs the SHA-256 digest (prehashed, marshaling_algorithm: jws), so the private key never leaves Vault. Policy and certificate steps are in Certificates and keys.

Key Default Meaning
vault.addr "" Vault or OpenBao address, for example https://vault.internal:8200. Empty falls back to $VAULT_ADDR; with neither set the configuration is rejected.
vault.mount transit Mount path of the Transit engine.
vault.key_name laidline Transit key name.
vault.key_version null Key version to sign with. null pins the latest version at process start; the version never changes while the process runs, so a rotation in Vault cannot sign with a key the certificate does not cover. Set it explicitly in production (laidline keys dev-cert --vault-key prints the version it certified).
vault.token_env VAULT_TOKEN Name of the environment variable holding the token. The token itself never goes in the file.
vault.namespace null Vault Enterprise namespace.
vault.ca_cert null PEM CA bundle for a Vault behind a private CA (relative to the config file). hvac ignores VAULT_CACERT; use this key (or REQUESTS_CA_BUNDLE).
vault.timeout_s 10 HTTP timeout per Vault call.

The evidence log records the key as vault-transit:<mount>/<key_name>@v<version> (c2pa.key_id, the checkpoint signer.key_ref).

watermark

Key Default Meaning
issuer_id 0 Your issuer number, 1 to 65535. Carried in every watermark payload (image, audio and video). laidline init draws a random trial number (32,768 to 65,279); shared marks carry it. laidline register never rewrites this value: private marks carry the registered number (1 to 32,767) from the installed issuer certificate (Licensing and metering: What a mark carries). Required when image, audio or video watermarking is enabled. A second host of a registered deployment carries the same value: copy the registered host's laidline.yaml rather than editing this field.
device auto Where AudioSeal and VideoSeal run: auto, cpu, cuda or cuda:N (see after watermark.video below).
cuda_precision strict The numerics of Laidline's models on a CUDA device. strict: full float32 with deterministic cuDNN (TF32 off for matmuls and convolutions, no cuDNN autotuning), set once per process when Laidline first places a model on CUDA. default: torch's settings are left as they are. Process-wide: see after watermark.video below.
image.enabled true Watermark every image with image.scheme.
image.scheme pixelseal pixelseal (PixelSeal, Meta, MIT: the 61-bit payload and a CRC-16 in a BCH code sent twice; survives quarter and half turns, most crops to 50 percent (87 of 100) the bench's synthetic screenshots and, through the photo box read, real browser screenshots (decoded on 39 of 40 fresh captures, confirmed to grade B on 37); quarter-turned and mirrored copies confirm through the turns of the query; a photo partly scrolled out of view decodes but stays at grade C; see below) or trustmark (TrustMark Q with B and C fallbacks). Detect reads the configured scheme only, so a deployment that switches keeps its older assets' scheme readable only by switching back.
image.deep_scan false pixelseal only: when the default reads find nothing, detect also reads the six other dihedral turns of the image and the seven other turns of the photo box (mirrors and quarter turns the direct read misses), 17 reads at most instead of 4; the false-accept bound grows with the reads (Grading).
image.variant Q trustmark only: primary TrustMark model. Q is the registered soft-binding alg (com.adobe.trustmark.Q).
image.fallback_variants [B, C] trustmark only: tried in order when the primary misses the PSNR floor.
image.min_psnr_db 38 Quality floor. If every variant misses it (PixelSeal has one) the asset is signed without a watermark and the evidence entry carries watermark_skipped:psnr_below_floor.
image.min_side_px 128 Images with a shorter side below this are signed but not watermarked (watermark_skipped:too_small).
image.decode hardened trustmark only: how detect reads TrustMark: hardened (soft-decision decoding with two confidence gates; see below) or fast (TrustMark's own hard decode, hard bits and its own BCH layer: the standalone opt-out, which the hardened decode does not run). The hardened decode costs 2^8 BCH decodes per variant (chase_flips 8), small. Keyed consequence: TrustMark has public-tier presence only under the hardened decode, so a keyed TrustMark image shows presence to uncredentialled callers by default (Payload keys, "Response tiers").
image.chase_flips calibrated (8) hardened only: Chase-II flips the p least reliable bits in every combination (2^p BCH decodes per variant). 0 to 12.
image.min_score calibrated (2.95) hardened only: the lowest accepted signed score of the best candidate. Must be above 0.
image.min_normalised_score calibrated (0.945) hardened only: the lowest accepted normalised score (signed score over mean absolute decoder output).
image.device cpu Where the image scheme (PixelSeal or TrustMark) runs (embed and detect): cpu, cuda, cuda:N, or auto (cuda:0 when torch can use it, as for video). A named CUDA device that is missing or unsupported stops the start. On an RTX 5080 a GPU halves mark time at 1024 px (p50 530 vs 980 ms on 2 CPU threads) and leaves detect unchanged (p50 246 vs 245 ms); PixelSeal, TSA per asset.

Decode: the fast decoder and the hardened decode

Each detector can read a mark two ways. The fast decoder (decode: fast) is a quick first look: it runs the open model's own detector (or a faithful re-implementation of it) once and catches a mark that is clearly present. The hardened decode (decode: hardened) is the product: soft-decision decoding, searches and calibrated gates with measured false-accept bounds. It is the default for image.decode (TrustMark scheme), video.decode and text.decode; audio.decode: auto resolves to it when the audio detector runs on a usable CUDA device and to the fast read otherwise. For audio, VideoSeal and text the fast read is the hardened decode's first stage, so a mark the fast read finds is reported as found by it (decode_path: fast). For TrustMark images and the keyframe scheme the hardened decode does not run the fast decoder at all; there fast is a separate, cheaper opt-out with its own false-accept behaviour, stated in each row below. Choose fast only where the hardened decode's cost cannot be met (costs are given per row). "Fast" names the decoder only; it is unrelated to the x264 preset video.preset: fast and to the audio phase-search grids. PixelSeal, the default image scheme, has one decoder and ignores image.decode. Video and text marking are off by default, so those decode defaults apply once they are enabled.

watermark.image.scheme: pixelseal (the default) embeds PixelSeal's 256-bit message: the 61-bit payload and its CRC-16 in BCH(127, 78) with t=7, sent twice (laidline.watermark.image_code). Detect reads the image directly; when that finds nothing it reads the image cut to its largest non-uniform rectangle (a screenshot's photo, when the image has a flat border), the image turned half a turn and the photo found inside a page capture (a screenshot with browser bars around the photo; laidline.watermark.image.localise), and accepts a payload only when exactly one distinct payload appears across those reads (two are ambiguous: no payload). Each read decodes with Chase-II (12 flips) and needs the CRC-16 and payload version 1. deep_scan: true adds, only when the default reads find nothing, the six other dihedral turns of the image and the seven other turns of the photo box. Registry confirmation compares the recorded fingerprint with the view that decoded and, when that does not confirm, with up to 18 fingerprints: the decoded view and the photo box in their 8 dihedral turns, the query and its cut upright. So a screenshot or a turned copy can reach grade B when one of those matches; a payload without a confirmed registry hit is grade C at most. The measured rates, the analytic false-accept bound and the timings are in Grading ("PixelSeal image scheme"). The weights (a pinned 1.24 GB checkpoint, kept beside the 309 MB slim model derived from it: 1.55 GB in models/pixelseal) download on first use like AudioSeal's and VideoSeal's; laidline models fetch (--image) fetches and slims them ahead of time. image.decode and the three gate keys below apply to the trustmark scheme only.

watermark.image.decode: hardened reads the TrustMark decoder's outputs before thresholding, decodes bits 0-95 with strict BCH_5 over Chase-II flips (the four version bits are ignored, so a flipped schema bit no longer loses the image) and accepts the best candidate only when its signed score and its normalised score both reach the gates and its payload version is 1 (a best candidate with another version is rejected; the next one is not tried). Variants run in the configured order and the first accepted one wins. The defaults were fitted on one half of the step-1 bench arrays and checked on the other half (laidline.bench.image_calibration; numbers and the validation history in Grading). Lowering either gate below its default gives up that measured false-positive budget, and Laidline logs a WARNING when it does: without the gates the same decoding returns a payload for 1,526 of 2,500 unmarked photos (61 percent, fit half). The gates do not stop confident wrong payloads on crops and rotations of marked images (Grading). Grading does not change: a payload still needs a confirmed registry hit for grade B. The video keyframe scheme (video.scheme: trustmark_keyframes) keeps the fast decoder under watermark.image.decode: hardened; watermark.video.decode: hardened replaces its key-frame vote with a vote of the hardened decoder and adds a window scan with the same decoder.

watermark.audio

AudioSeal on audio (audio): the library, /v1/mark/audio, ingest, the speech proxy and the streaming plugins. Needs the audio extra; without it audio calls fail with audio_unavailable.

Key Default Meaning
audio.enabled true AudioSeal on every audio mark: the 16-bit payload (the issuer only; detect reports presence only, grade D) unkeyed, the keyed audioseal-p70-r1 payload in keyed mode (Payload keys, "Audio").
audio.scheme audioseal The only audio scheme.
audio.alpha unset Watermark strength passed to AudioSeal (above 0, at most 2). Unset, it is 1.0 unkeyed (and in keyed mode with payload.layout: none) and 1.41 for the keyed payload, which Laidline sets: a keyed configuration with the payload that names any other value is refused at start (the payload's figures were measured at 1.41).
audio.payload.layout audioseal-p70-r1 Keyed mode only. The keyed audio payload, or none: keyed mode then writes the 16-bit mark (grade D at most) and logs a WARNING at start.
audio.payload.reader three_grid Keyed mode only. The first read: three 20 ms grids (three_grid) or five (five_grid).
audio.payload.hardened_read off Keyed mode only. Deep scanning (the second read, for trimmed, offset or excerpted audio): off, auto (on a CUDA device; on CPU hardened_cpu decides) or on. YAML's bare off and on mean these names. GPU-recommended (Benchmarks).
audio.payload.hardened_cpu skip Keyed mode, hardened_read: auto without a GPU: skip (the full result says payload_read: not_attempted, no_gpu) or run.
audio.payload.span_trigger auto Keyed mode. The payload of a mark inside a longer recording: auto and hardened read it exactly when audio.decode resolves to hardened (so auto is on on a GPU host and off on a CPU-only one under decode: auto); hardened with a fast decode reads nothing and says so at startup; off never reads it. When the first read did not detect and the best 12 s window of the batch card's shares at the searched phase is at least 0.5, it reads that window at the phase search's coarse peaks, then at the searched phase, stopping at the first choice; one tag check per call. The full tier then shows extra.span_read and payload_read.span; the false-accept bound counts its 72 words (Payload keys). YAML's bare off means off.
audio.payload.phase_search off Keyed mode. The search for a mark that does not start on a 20 ms boundary of the clip (a delayed or trimmed recording, a codec's lag): on or off; YAML's bare off and on mean these names. It runs only under a hardened decode (audio.decode resolving to hardened), when the batch card detects and the first read chose nothing, and reads the best phases it finds, stopping at the first choice. Off by default. Cost when on: about +0.5 s per read at p50 for a search read (10 s clips). Gain when on, in the v2 matrix of 2026-10-30 (pooled over two runs, n 200 per cell, tag verified): a 9.5 s excerpt off the grid 6 % to 97 %, a 10 s clip with a delayed start 0 % to 95.5 %, G.722 0 % to 96 %, AMR-WB 0 % to 100 %. Why off: on 26,000 unmarked units the search chose a keyless payload twice (tag accepts: 0), and the pre-registered line allowed none; on an L4 GPU five search cells (pooled count; G.722 as well on the L4 alone) ran 7 to 10 % over the latency budget. With it off, a call reads no search words (the false-accept bound in Payload keys states the larger worst case).
audio.min_duration_s 0.5 Shorter audio is signed without a watermark (watermark_skipped:too_short).
audio.max_sync_duration_s 120 Longest audio mark_audio, mark_bytes, ingest, /v1/mark/audio and the speech proxy mark: longer audio is audio_too_long (413 on the routes, a failed entry), counted while decoding, which stops there, so container headers that understate the length do not get past it. A streaming session keeps chunks for its signed twin up to this length (longer: twin_skipped:too_long); a streamed proxy response stops marking at it, its entry gets extra.incomplete and it counts as laidline_marks_total{modality="audio",status="unmarked"}. In keyed mode it may not exceed 120, the length of a keyed audio entry's fingerprint record (Evidence log, "Keyed audio entries"): a larger value is refused at start.
audio.max_detect_duration_s 120 detect answers an error result audio_too_long for audio that decodes to more than this plus 1 s; decoding stops there, so nothing longer reaches the detector. The 1 s of slack is for codec padding: AAC priming and padding make Laidline's own 120 s m4a or aac decode to about 120.02 to 120.06 s, which equal mark and detect caps would otherwise refuse. The detector's memory grows with the length: measured on the development CPU, about 1.1 GB of peak RSS for 120 s and 2.7 GB for 300 s (about 9 MB per second), so the default holds one detect to about 1.1 GB; raise it only on hosts with the memory (600 s would take about 5.5 GB by extrapolation).
audio.detect_threshold 0.5 Between 0 and 1, exclusive. detect reports an AudioSeal watermark when the detector's score reaches this. The benchmark's operating point is 0.39 (0.75 percent false positives on 530 unmarked clips, against 0.38 percent at 0.5). The marking-method text quotes detection at this threshold when the bench counted at it (0.39 or 0.5) and says so when it did not (Benchmarks).
audio.speech_mode buffered Speech proxy: buffered (mark the whole utterance, sign mp3, flac and wav) or streaming (mark 20 ms chunks as they pass). A client can pick per request with x-laidline-speech-mode.
audio.speech_pcm_rate 24000 Streaming speech: the sample rate of the upstream's pcm (16000 to 48000: the streaming mark does not survive below 16 kHz).
audio.stream_chunk_ms 20 Streaming speech: audio accumulated before each mark-and-relay step (10 to 200).
audio.stream_kind block Streaming embedder: block only (AudioSeal's streaming card, each 20 ms block on its own); the key stays for forward compatibility. A context-carrying kind is not offered: it kept only 22.4 % identity on real speech (Benchmarks), so context is refused.
audio.store_signed_twin true Streaming speech: when a stream runs to its end, store the whole marked stream as a signed mp3 under the asset id (x-laidline-signed-twin-url).
audio.decode auto auto: resolved at start, and logged once, to hardened when the audio detector runs on a usable CUDA device (watermark.device names one, or is auto on a host with one) and to fast otherwise (a CPU-only host, or watermark.device: cpu). Resolving auto imports torch while the configuration is validated, to ask whether a usable GPU is present (measured: about 1 s with a warm import cache, up to about 13 s on a cold network or virtualised file system); with audio.enabled: false the probe is skipped and auto resolves to fast. An explicit fast or hardened always wins. When auto chose hardened, the full tier's signal records extra.audio_decode. Excerpt detection (a mark inside a longer clip) and, in keyed P70 deployments, the cell gate and the located path (Grading, "The cell gate for keyed P70 audio") live in hardened; on 2 CPU threads the hardened decode costs about 41 s per 30 s clip and 54 s per 120 s clip (these are longer clips than the 6.3 s clips of the 11.8 s and 23.8 s figures below, so the two sets of figures are not comparable per clip). How detect reads AudioSeal. fast: the fast read (AudioSeal's own detector on the whole clip, batch then streaming card at detect_threshold). hardened, in order: (1) the fast read at detect_threshold; when it detects, its verdict stands and the 16 bits are read by the detector that fired at the best of the 320 phases of AudioSeal's 20 ms bit grid (a trim or cut moves it), phase 0 kept unless another phase reads at least 5 % more confidently; (2) a clip quieter than -30 dBFS is raised to that level and decoded again; (3) per detector, the phase search, then the whole-clip share at that phase against its own threshold (batch 0.13, streaming 0.41); (4) the best 1 s window at that phase (batch 0.70, streaming 0.93). Before (3) and (4) the batch card's search counts only if its whole-clip or 10 s fine-pass message confidence reaches 0.305 (the fine-read floor; Grading); a search removed that had reached a threshold is recorded in the signal's extra. In (3) and (4) the detector that fired reads the bits; when both fire, the one further over its own threshold reads them. Phases are chosen by the confidence of the bits alone, never by the payload. The thresholds were fitted on LibriSpeech test-other and hold the whole decode's false detections at 0.75 % on the held-out half (11 of 1,470; the fast read alone: 3), against 0.20 % for the fast read: Grading, "Hardened audio decode", has the fit, the validation and the wrong-issuer rates. It is GPU-recommended: on a CPU with 2 threads it took a median of 11.8 s per detected 6.3 s clip and 23.8 s per unmarked one (the fast read: 0.1 to 0.2 s), far above the 0.1 x duration + 1.5 s budget; a cheaper search grid was measured and rejected (Grading). The signal gains decode_path, path_score, phase_samples, phase_confidence, attempts and gain_db, and extra when the fine-read floor removed a search.

watermark.video

VideoSeal or keyframe TrustMark on video (video): the library, the async job path (POST /v1/mark/video) and ingest. Needs the video extra; the audio track also needs audio.

Key Default Meaning
video.enabled false Video marking, the job routes and the job store. Off by default.
video.scheme videoseal videoseal (VideoSeal 1.0 on every frame, 92-bit payload) or trustmark_keyframes (the CPU stopgap: TrustMark on one forced IDR frame per keyframe_interval_s).
video.model videoseal_1.0 The only VideoSeal card.
video.frames_per_call 32 Frames embedded per model call; a multiple of 4 (VideoSeal's step size).
video.crf 18 Quality of the re-encode (0 to 51, lower is better): libx264's crf. Another video.encoder gets it mapped to its own quality setting (video).
video.preset medium libx264 preset, ultrafast to veryslow. Ignored, with a WARNING at load, when it is set and video.encoder is not libx264.
video.encoder libx264 The H.264 encoder of the re-encode: libx264, libopenh264 (an LGPL FFmpeg build) or h264_nvenc (NVIDIA). Other FFmpeg H.264 encoders are not supported yet. Every published video figure is for libx264. Any other encoder (or libx264 with video.encoder_options) is tried on one frame at start-up; a failure is video_encoder_unavailable: <encoder> (a configuration error: /readyz reports startup_failed). See video.
video.encoder_options {} Options passed to the encoder (FFmpeg option names, string values), laid over the ones mapped from video.crf. A bad value or a name the encoder does not take fails the encode (encode_failed).
video.keyframe_interval_s 1.0 trustmark_keyframes: one marked IDR frame per interval.
video.mark_audio_track true Also AudioSeal-mark the audio track (needs watermark.audio.enabled); otherwise it is copied.
video.max_duration_s 600 Longer video is refused by mark (video_too_long) and by detect (an error result). Mark also counts the frames it decodes and stops past this duration's worth plus one second, whatever the header says.
video.max_pixels 8294400 3840x2160. A larger frame is refused by mark (video_too_large, not retried by ingest) and by detect (an error result), by the stream header before any frame is decoded; marking also checks every decoded frame, so a mid-stream size change is refused too.
video.max_detect_frames 256 detect samples at most this many frames. It still decodes the video stream up to the last sampled frame.
video.trustmark_runtime onnx trustmark_keyframes: how the reader runs TrustMark Q's decoder forward on a CPU device (the hardened and keyed reads). onnx: ONNX Runtime (MIT, 1.24.4) on the pinned FP32 graph decoder_Q.fp32.onnx (Third-party licences), whose size and SHA-256 are checked before it loads; a file that differs is refused (weights_corrupt, both digests named) and never replaced by torch. Without the graph or onnxruntime, the reader runs Q on torch and logs one WARNING. torch: runs Q on torch. The fast decoder, image decodes, variants B and C, marking and a GPU device always use torch. The ONNX path is decision-equal to torch on the measured sets and 1.55 to 1.80x faster on an unmarked clip at 2 threads and 1.65 to 1.75x at 8 (Benchmarks: Keyframe reader on ONNX Runtime; AVX-512 host, the gain elsewhere is unmeasured).
video.trustmark_threads 2 ONNX Runtime's intra-op threads for trustmark_runtime: onnx (0: ONNX Runtime's default, the physical core count). Its logits do not depend on this count (checked on 120 units at 0, 4 and 2 threads).
video.decode hardened VideoSeal detection. fast BCH-decodes the signs of the mean logits of the sampled frames. hardened keeps every decode the fast decoder makes. When the fast decoder finds nothing, it runs three soft attempts: the mean logits, VideoSeal's l1norm_avg vote, and 4 contiguous windows of the sampled frames. Each runs Chase-II over the chase_flips least reliable of the 216 bits the BCH decoder reads, then strict BCH (t=16) and the payload CRC-16. A payload is accepted only when every soft input that yields one agrees on it; two distinct payloads anywhere give no decode (ambiguous), and so does a non-finite logit on a read bit (non_finite). The signal then also carries decode_path, score, normalised_score and attempts (Grading, which also states what is and is not established: the analytic bound, the null draws, 0 of 4,854 unmarked clips in the 2026-10 null campaign and 0 of 43 clips from one corpus on the earlier bench, no score gate). The analytic false-decode bound for a uniformly random read rises from 2^-60.9 to 2^-60.9 x (1 + 6 x 2^chase_flips): 2^-48.3 at 10, 2^-46.3 at 12. Measured offline on the saved bench arrays (laidline.bench.video_robustness.rescore_file, the saved float16 per-frame logits): 3,141 exact decodes of 3,956 marked clip-rows against 2,941 for the fast decoder, none lost, no wrong payload, no ambiguous or non-finite row, 0 of 43 unmarked clips decoded. With scheme: trustmark_keyframes, hardened does not run the fast key-frame vote: the same key frames and marking positions vote through the image hardened decoder (watermark.image gates, payload version 1), a frame counting only when that decoder accepts it, and the vote gives a payload only when exactly one was accepted (two or more: ambiguous, no decode, no scan) and it stands over the vote frames' pooled reads (else also ambiguous; Grading, "Pooled consistency"). When no frame is accepted it scans every frame within 0.5 s of an expected mark position, rounded outward to whole frames (at most max_detect_frames, in stream order; no analytic false-decode bound exists for this scan, see Grading) with the same decoder and accepts a payload only when it is the only one accepted in the video and it stands over the scanned frames' pooled reads (else ambiguous); the signal adds decode_path (vote, scan, ambiguous or null), attempts (frames the gated decoder read: the vote's, plus the scan's) and scan_window_s. Under fast the key-frame vote uses the ungated image decode and a single frame can decide it: on 600 unmarked YouTube UGC clips it decoded 3 (0.50 %, Wilson 0.17 to 1.46 %; TrustMark on the CPU, the default; the same 3 clips on CUDA) and on 520 further clips 3 (0.58 %, 0.20 to 1.68 %, CPU), each on one phantom frame whose payload version is not 1, so grade D (signals_present_unverified, with the phantom caveat). hardened does not use that vote: 0 of 600 and 0 of 520 decode (CPU). Cost on an unmarked 5 s CIF clip (ONNX Runtime, 2 threads, indicative): 5.491 s for the hardened reader (torch 9.859 s) against about 0.7 s for the fast key-frame vote. For VideoSeal the hardened decode's geometry search adds about 4.5 s per unmarked upload on the GPU (up to 19 extra hypotheses, each a full hardened decode); it is not timed on CPU. The gated vote removes the ungated single-vote decode; measured cost on derf 2 lost of 3,280 marked rows (0.06 %; 2 of the 1,940 rows the fast decoder decodes exactly, 0.10 %), 0 new wrong (1 wrong, the one the earlier scan rule also accepted; the earlier rule had 12); on YouTube UGC 5 lost of 3,600 marked rows (0.14 %, Wilson 0.06 to 0.32 %; 5 of the 2,455 rows the fast decoder decodes exactly, 0.20 %; 100 clips), 0 wrong (the earlier rule had 10). TrustMark on CUDA for both marked sets. Each lost row rested on one ungated vote at the BCH correction limit (5 bit errors) on a frame under the gates (Grading). After a scan, frames_sampled is the scan's frame count. In keyed mode (payload_keys) the keyframe scheme's reader is the hardened Q-only reader whatever this says, with a start-up WARNING under fast (Payload keys, "Video").
video.chase_flips 10 hardened only: the Chase-II flip count p (0 to 12). Each soft input decodes 2^p words, and a video where the fast decoder finds nothing decodes six soft inputs.
video.geometry_search.enabled true hardened only (VideoSeal). When the hardened decode of the frames as received finds nothing (not ambiguous, not non_finite), the same sampled frames are read again under geometry hypotheses: the picture box inside uniform-colour bars that every sampled frame agrees on (letterbox, pillarbox, a screen capture's canvas), cut out and resized, then the frame taken as 90 and as 80 percent of each original side, pasted at 9 anchors (centre, corners, edge midpoints) on mid-grey. Each hypothesis goes through the full hardened decode; a payload counts only when every hypothesis that gives one gives the same one (else ambiguous). The signal adds hypothesis, hypothesis_path and hypotheses, and decode_path is geometry (Grading). Cost on a video with no payload (every unmarked upload): two more reads of the stream and up to 19 times the detector's forward passes.
video.geometry_search.max_hypotheses 19 hardened only: try at most this many hypotheses, in order (the box when bars were found, then the 9 anchors at 0.9, then at 0.8). Each adds 1 + 6 x 2^chase_flips candidate words to the analytic false-decode bound: 20 x 6,145 = 122,900 words at the defaults, 2^-44.0.

watermark.device places VideoSeal and AudioSeal: auto embeds AudioSeal marks on the CPU, and runs AudioSeal detection and VideoSeal on cuda:0 when torch sees a GPU it can run, else the CPU (audio detection under auto uses the GPU; where marks are embedded does not change); on such a host the AudioSeal generator (embedding) stays on the CPU and the two AudioSeal detectors run on the GPU, so the process holds one copy of each model on the device that uses it: the generator's weights in CPU memory, the detectors' weights and their activations in GPU memory; cpu, cuda or cuda:N forces both, and a cuda value without a visible GPU stops the start with a configuration error. So does a GPU whose architecture the installed torch has no kernels for (kernels of the same major with an equal or lower minor, or PTX at or below the GPU's capability, count, so the cu130 wheels run everything from compute capability 7.5 up, an 8.9 L4 or a 12.1 GB10 included, and refuse a 7.0 V100); with auto, VideoSeal then runs on the CPU and a WARNING says why. The CUDA image needs driver R580 or newer (Video). The image scheme runs where watermark.image.device says (default cpu); the keyframe scheme reuses the image watermarker when it is TrustMark, and runs its own TrustMark on the CPU when image watermarking is off or uses PixelSeal. Weights for AudioSeal and VideoSeal are not bundled: they download on first use into $LAIDLINE_HOME/models (~/.cache/laidline/models without it), pinned by SHA-256; laidline models fetch downloads them ahead of time and LAIDLINE_OFFLINE=1 forbids the download (it does not change timestamping: see tsa.required).

watermark.cuda_precision sets how those models compute on a GPU. With strict (the default), the first time Laidline places a model on a CUDA device (AudioSeal detection, VideoSeal, or the image scheme under watermark.image.device), and before that model loads, it sets torch's torch.backends.cuda.matmul.allow_tf32 and torch.backends.cudnn.allow_tf32 to False, torch.backends.cudnn.deterministic to True and torch.backends.cudnn.benchmark to False, and logs this once. These four flags are torch's own, and they are process-wide: Laidline runs inside your process, so they also apply to any other torch work in that process (your own models run at full float32 too, which can be slower on GPUs that support TF32; Laidline's own AudioSeal and VideoSeal detection showed no material change on an RTX 5080). Laidline sets them once; a value your code sets after that stands. With default, Laidline leaves the flags as torch or your code set them (torch's default allows TF32 in cuDNN convolutions on Ampere and newer GPUs); if another Laidline instance in the same process already set strict, the flags stay strict and a WARNING says so. A CPU-only deployment is unaffected either way.

strict is the default because every detection figure Laidline publishes for a GPU was measured at full float32 with deterministic cuDNN, and TF32 changes the arithmetic enough to move decisions whose statistics sit close to a threshold: on 350 hard audio clips, the GPU detector under torch's default TF32 decided differently from the CPU on 9 units (presence on 4 of them, the decode path on all 9); at full float32 no decision differed and the path on 1 (Robustness baseline, "GPU against CPU, as shipped"). TF32's effect also depends on the GPU generation and the cuDNN version, so a TF32 figure measured on one host would not transfer to another. Choose default only if the process-wide setting conflicts with other work in the process and you accept detection on the GPU that may differ, on near-threshold inputs, from the published figures and from a CPU deployment.

watermark.text

Text is marked by the laidline-vllm plugin or laidline-transformers, both reading this section, and logged by the proxy (vllm). The plugin, the proxy and the detector must share key_seed and key_id.

Key Default Meaning
text.enabled false Turns on proxy injection and accounting, the plugin and text detection. laidline init writes true with a random seed.
text.scheme synthid synthid (sampled traffic; no effect at temperature 0) or kgw (works under greedy decoding; looping greedy output may grade D, since a repeated n-gram counts once).
text.key_seed "" Secret from which every text key is derived. At least 16 characters when enabled. Keep it secret: whoever has it can produce text that detects as yours.
text.key_id k1 Key id sent to the plugin and recorded per entry (with the key's fingerprint); room for rotation.
text.ngram_len / text.depth 5 / 30 SynthID context length and tournament depth. Changing either changes the watermark.
text.sampling_table_size 65536 SynthID sampling table size.
text.context_history_size 1024 Contexts remembered per request for SynthID's repeated-context rule.
text.min_tokens 200 Completions shorter than this are recorded unmarked / below_min_tokens, and detection over fewer tokens grades D at best (for KGW, fewer tokens covered by distinct n-grams: a repeated n-gram counts once).
text.require_plugin false Proxy: answer 503 plugin_missing when the upstream does not acknowledge the plugin, instead of an unmarked entry.
text.detect_models [] Tokenizer ids /detect accepts for text, matched exactly. Empty disables text detection over HTTP. In-process marker.detect is looser: an empty list allows any tokenizer id, a non-empty list allows its members, and a local tokenizer directory is always allowed.
text.detect_p_value 0.001 A text signal is present below this p-value. For KGW the p-value is the exact binomial upper tail of the green count (z is reported for reading); SynthID uses a z-test.
text.grade_b_p_value 0.00001 A present text signal is grade B only when the reported test's p-value is also below this (and it covers min_tokens); otherwise D. That is the fast test's p-value, or a fallback detection's corrected p-value (which must clear the smaller of this and fallback_grade_b_p_value). At most detect_p_value (checked at start-up). Before this gate grade B was the presence gate itself, so about 5 human texts per 5,000 reached B by construction; the 2026-10 null campaign (20,800 human documents) found grade B in 19 of 20,000 SynthID and 32 of 20,000 KGW documents at 1e-3. The re-score at the shipped gate (1e-5, exact KGW tail): grade B on 0 of 20,000 per scheme, KGW presence 32 of 20,000, and none of the 800 marked positives lost presence or B (Grading, "Per-signal grades"). Grade B at 1e-5 needs about 1.9 times the tokens of the 1e-3 gate for the same per-token signal; that cost on short or edited marked text is not yet measured. The signal adds grade_b_p_value and grade_b_met.
text.max_detect_chars 100000 /detect refuses longer text with 413.
text.max_detect_tokens 8192 Only the first N tokens are scored; a caveat says so.
text.kgw.greenlist_ratio / bias / context_width 0.25 / 2.0 / 1 KGW green-list share, logit bias and context tokens.
text.kgw.max_detect_tokens 1024 Under decode: fast, KGW scores at most this many tokens (the smaller of this and max_detect_tokens). Under decode: hardened it caps the fast test, which runs first; when that does not detect, KGW goes on up to max_detect_tokens (vectorised scoring; the time budget of 1.5 s for 8,192 tokens on 2 CPU threads is confirmed only by a measurement taken under heavy machine load, see Grading).
text.decode hardened fast: one z-test over the scored tokens. hardened: the fast test first; if it detects, that is the answer, so no text gets a lower grade than under fast. For KGW only, when it does not detect, the fallback tests the whole text up to max_detect_tokens and windows of 128, 256 and 512 tokens at half-window steps, on exact binomial p-values, with its own budgets (fallback_p_value, fallback_grade_b_p_value; the KGW fallback costs about 1.5 s per 8,192 tokens on 2 CPU threads, confirmed only under heavy machine load). SynthID answers exactly as under fast (one hypothesis): on 500 human texts a window scan raised its false positives from 1 to 3 (the bench's FPR guard fails) for a few points on harsh edits. The signal adds decode_path, window, hypotheses, the fallback's budgets and p-values, fallback_grade_b_met and bound_total. KGW finds a marked passage inside longer human text (10 percent share: 32 to 100 percent sampled, 6 to 72 percent greedy; greedy at a 25 percent share 17.0 to 79.0 percent, against 95.0 percent unedited: a missed target, which was to come within 5 points of the unedited row). See Grading.
text.fallback_p_value 0.001 Hardened KGW fallback: detects when its smallest exact p-value times its number of tests is below this. The nominal false-positive bound is detect_p_value + this (0.2 percent); the fast test's p-value is itself the exact binomial tail. Measured on human C4 text under random keys, the fast test alone and the whole decode: 1,024 tokens 0.14 and 0.24 percent; 3,000 tokens 0.10 and 0.16 percent; 8,192 tokens 0.14 and 0.24 percent (5,000 trials each; every flag the fallback added was grade D). The fast-test rates were taken on the normal tail, before 2026-10-28, and are higher than the exact tail would give. Lower it to trade greedy-KGW gains for fewer flags (1e-4: 0.16 percent at 1,024 tokens; greedy embed_25 71.0 instead of 79.0 percent).
text.fallback_grade_b_p_value 0.0001 A fallback detection is grade B only when its corrected p-value is also below this (and it covers min_tokens); otherwise D. At most fallback_p_value (checked at start-up). The effective budget is the smaller of this and grade_b_p_value, so at the default grade_b_p_value of 1e-5 it is 1e-5 and the signal's fallback_grade_b_p_value reports that effective value. Under one threshold of 0.001 the fallback gave grade B to 2 of 5,000 human texts at 1,024 tokens, 2 of 5,000 at 3,000 tokens and 3 of 5,000 at 8,192 tokens under random keys; with this budget none (their corrected p-values were 1.2e-4 to 6.7e-4; measured before 2026-10-28, with the fast test on the normal tail).

watermark.payload_keys

Keyed watermark payloads (Payload keys says what a key does and does not do). Image, video and audio marks are keyed in keyed mode (the keyframe scheme's layout is carried and read by TrustMark Q alone; audio carries audioseal-p70-r1 unless watermark.audio.payload.layout: none). What else changes in keyed mode: /detect serves the public response tier (detect.unauthenticated), the public tier shows only the presence of an audio watermark, key events are recorded in the evidence chain, and the S3 sink writes two decoy objects. Absent (or mode: unkeyed): the unkeyed formats and grading, and a startup WARNING when image, video or audio marking is enabled. laidline init writes the section enabled, with a payload-k1.key secret file (mode 0600) beside the configuration. The environment overrides apply here too: LAIDLINE_WATERMARK__PAYLOAD_KEYS__MODE=unkeyed switches keyed mode off and LAIDLINE_DETECT__UNAUTHENTICATED=full serves the full result to everyone, so whoever sets the process environment controls both.

Key Default Meaning
payload_keys.mode keyed keyed or unkeyed (the unkeyed payloads and grading, for fixtures and development).
payload_keys.family routable routable only (the issuer stays readable as an unverified claim); private is refused (reserved). This is the layout family of the keys in the configuration, not the "private key" of the registration states: those are the configured routable keys, as opposed to the shared pool key the directory supplies (Payload keys: The shared family and the private key).
payload_keys.max_live_keys 16 Non-retired keys per medium, 1 to 16. A medium whose scheme is a TrustMark carrier (image.scheme: trustmark, video.scheme: trustmark_keyframes) accepts at most 2.
payload_keys.keys[].id (required) [a-z0-9][a-z0-9-]{0,15}, unique; part of the key derivation.
payload_keys.keys[].secret_file A file holding 64 to 128 hexadecimal characters (laidline payload-keys generate --out FILE writes one with mode 0600). Unless LAIDLINE_ENV is explicitly development: refused when group- or world-readable or writable, or owned by another user than the process (or root); a symlink is checked at the file it names.
payload_keys.keys[].kms_ciphertext_file An AWS KMS ciphertext of the hex secret, decrypted once at start-up (kms:Decrypt, pinned to kms_key_id and the encryption context {purpose: laidline-payload-key, key_id: <id>}; region and credentials from the standard AWS environment). Needs the kms extra and kms_key_id.
payload_keys.keys[].kms_key_id Required with kms_ciphertext_file: the KMS key (id, ARN or alias) the ciphertext must come from.
payload_keys.keys[].secret_env An environment variable holding the hex secret: the weakest option (readable through /proc/<pid>/environ, container inspection and crash dumps).
payload_keys.keys[].secret Inline hex secret: only when LAIDLINE_ENV is explicitly development.
payload_keys.keys[].state active active (marks and verifies; exactly one per enabled medium), verify (verifies only), compromised (verifies only for registry entries recorded before compromised_at), retired (verifies nothing).
payload_keys.keys[].media [image, video, audio] The media the key serves.
payload_keys.keys[].compromised_at null Required with state: compromised, refused with any other state; a time with a zone.

Exactly one of secret_file, kms_ciphertext_file, secret_env (or, in development, secret) per key. A secret that is not 64 to 128 hexadecimal characters, or repeats one byte value, is refused, and so is a published example secret (a deny-list) unless LAIDLINE_ENV is explicitly development; an unset LAIDLINE_ENV is strict (see Environment). The live-key limits hold for every medium, its marking enabled or not. A misspelt key in this section, detect.credentials, detect.state or detect.alerts is an error. Error messages name the key id, never a value.

evidence

Key Default Meaning
sinks[] one file sink Where entries and checkpoints are written, in order. Every sink names its type (file, s3 or postgres). A sink without type that has only file-sink keys (path, rotate) is read as type: file; any other sink without type is a configuration error naming the entry (evidence.sinks[N] needs \type``). The first sink is authoritative (below).
checkpoint.enabled true Signed, timestamped checkpoints of the chain head (Evidence log).
checkpoint.every_n 1000 Checkpoint after this many entries (checkpoint events do not count). 0 turns the count trigger off.
checkpoint.every_seconds 300 Checkpoint once the oldest unanchored entry is this old, checked on every append and, in serve and ingest (or after Marker.start_background()), by a timer thread at least once a minute. 0 turns the age trigger and the timer off.
checkpoint.on_close true Marker.close() checkpoints the unanchored tail, so a one-shot laidline mark anchors its own entry. With a TSA configured but unreachable, close waits up to about 12 s for the TSA (see tsa.timeout_ms).
include_prompt_hash false Reserved for opt-in salted prompt hashes; it has no effect.
retention_days 0 Reserved; it has no effect (entries are kept forever). S3 Object Lock retention is set per sink.
replicas sync How sinks[1:] are written. sync: in order, under the chain lock, before the mark returns. async: each replica from its own queue and thread (below). The first sink is always written synchronously.
replica_queue_max 10000 async only: entries queued per replica before new ones are dropped (and counted).
replica_drain_timeout_s 30 async only: how long close() waits for the queues to drain at shutdown.

Authority and failures. sinks[0] is authoritative: detect and the ingest duplicate check read through it (file: a linear scan of the directory; S3: pointer objects; Postgres: indexed columns), and laidline evidence export reads it unless --sink says otherwise. A failed write there fails the mark with evidence_unavailable under both on_mark_failure policies: fail_open would log its unmarked entry to the same primary, and no asset is ever returned without its log entry. Later sinks are best-effort replicas: a failed write there is logged, counted (laidline_evidence_sink_failures_total{sink="<type>:<index>"}) and skipped, the chain advances, and a verify of that replica shows the gap. For detect at scale, Postgres is the recommended primary (indexed lookups, one INSERT per entry); an S3 Object Lock bucket makes a good WORM replica, or a primary where marking volume is modest (every marked entry costs three S3 PUTs in two sequential phases, see Benchmarks).

Async replicas (replicas: async). Each replica gets a FIFO queue of replica_queue_max entries and its own writer thread; the mark pays only the primary write. A failed replica write is retried twice (0.5 s, doubling). The trade:

  • the replica lags the primary, and a reader of the replica may briefly see a checkpoint before the entries it anchors have landed there;
  • a full queue drops the entry: it is counted in laidline_evidence_sink_failures_total and laidline_evidence_replica_dropped_total, and logged once per overflow episode;
  • at shutdown close() drains for up to replica_drain_timeout_s; entries still queued then are counted in laidline_evidence_sink_failures_total only (laidline_evidence_replica_dropped_total counts full-queue drops). A crash or SIGKILL before close() loses whatever is queued, without counting it;
  • verify on the replica reports every gap later, not at mark time.

Watch laidline_evidence_replica_queue_depth (or replica_queue_depth in GET /admin/evidence/status). Measured guidance (Benchmarks): an S3 write in the bucket's region costs p50 54.9 ms and p95 77.0 ms, so sync stays the default and the recommendation in region; use async for an S3 replica in another region or behind a long network path (p50 533 ms, p95 556 ms from outside the region).

Keep credentials out of the file: S3 uses boto3's default chain (environment, profile, instance or task role), and Postgres should read its DSN from dsn_env. One process has one boto3 default session, shared by every S3 sink and the S3 ingest store, so they all use one set of credentials: an AWS primary plus an R2 or SeaweedFS replica that needs different credentials is not possible in one process.

type: file

Key Default Meaning
path /var/lib/laidline/evidence Directory for <YYYY-MM-DD>/<stream>.jsonl and checkpoints/. Relative paths resolve against the config file. Must be on a filesystem with hard links (checkpoints are linked into place; Evidence log).
rotate daily Only daily (one directory per UTC day).

type: s3

S3, or an S3-compatible store through endpoint_url (the evidence extra brings boto3).

Key Default Meaning
bucket required Evidence bucket. The worker needs s3:PutObject, s3:GetObject, s3:ListBucket and, with object_lock, s3:GetBucketObjectLockConfiguration (plus s3:PutObjectRetention with retention_mode); never s3:DeleteObject*. See Evidence log.
prefix laidline/ Key prefix; empty or ending in /.
region null Bucket region; null uses boto3's default.
endpoint_url null Custom endpoint: Cloudflare R2 (https://<account>.r2.cloudflarestorage.com) or SeaweedFS (http://127.0.0.1:8333 when you run it on the same host). With any endpoint_url and object_lock: false the client sends checksums only when an operation requires them, since some stores reject the default CRC checksums; with object_lock: true they stay on, because Object Lock puts need one.
object_lock false Require S3 Object Lock: the sink reads the bucket's Object Lock configuration at start and refuses to start without it. R2 has no S3 Object Lock API, so object_lock: true on R2 fails fast with "object_lock is true but the Object Lock configuration of bucket ... could not be read (...); R2 has no S3 Object Lock API".
retention_mode / retention_days null / null GOVERNANCE or COMPLIANCE, and a day count: every put then sets its own retention. Both or neither, and only with object_lock: true. Without them the bucket's default retention applies.
conditional_put true Every entry, checkpoint and pointer put carries If-None-Match: *, so nothing is silently overwritten and a second write to a seq is detected. Turn it off only for a store without conditional writes.

type: postgres

PostgreSQL through psycopg 3 (evidence extra). Set up the schema with laidline evidence init-db (Evidence log).

Key Default Meaning
dsn "" libpq connection string. Prefer dsn_env: a DSN in the file usually carries a password. When set, it wins over dsn_env.
dsn_env LAIDLINE_PG_DSN Environment variable holding the app role's DSN.
schema_name laidline Schema holding evidence_entries and evidence_checkpoints.
auto_init false Development only: create the tables as the connecting role, which then owns them and can drop the append-only triggers. In production run laidline evidence init-db as a separate migrator role. Without the tables and without auto_init the sink refuses to start and names the command.
connect_timeout_s 5 Connection timeout in seconds, for the write connection and the read pool.
read_timeout_s 60 statement_timeout of the read pool (exports, registry lookups). The write connection keeps 5 s, as it writes under the chain lock. Both are sent as a startup options parameter, which PgBouncer and RDS Proxy in transaction mode ignore (Evidence log).

Top level and detect

Key Default Meaning
on_mark_failure fail_closed fail_closed raises MarkError and logs a failed entry. fail_open returns the re-encoded, unmarked asset and logs an unmarked entry with the reason.
detect.public true Deprecated and ignored: a file that sets it logs one WARNING. The response tier is detect.unauthenticated.
detect.unauthenticated public_tier Keyed mode only (watermark.payload_keys with mode: keyed): what /detect serves a caller without an X-Laidline-Detect-Token header (/detect does not read Authorization). public_tier serves the public projection (grade B or D only; no fingerprints, distances, scores, unconfirmed ids or tag fields; Payload keys), full serves the full result as it does without keys, refuse answers 401. In unkeyed mode /detect answers every caller with the full result exactly as before.
detect.credentials [] Keyed mode only: detect credentials for the full tier, each {id, token_sha256, tag_failure_budget_30d}, presented in the X-Laidline-Detect-Token header (MarkClient: detect_token or LAIDLINE_DETECT_TOKEN). token_sha256 is the 64 lowercase hex characters of SHA-256 of the token (Laidline never stores the token); use a high-entropy token, for example python -c "import secrets; print(secrets.token_urlsafe(32))". The empty token and two credentials with one token are refused. A request whose header matches none answers 401. A credential over its tag-failure budget in the last 30 days gets the public tier until the window clears.
detect.credentials[].tag_failure_budget_30d 1000 Tag failures allowed per credential per 30 days; failures on a 16-bit-tag (TrustMark) layout are budgeted at the smaller of this and 100 (Payload keys). At least 1.
detect.state.backend memory The detect-state store of keyed mode: memory (per process, lost on restart) or sqlite (one host). Counts tag failures, unconfirmed hits per asset and credential budgets; written off the request path.
detect.state.path null The SQLite file (relative to the config file); required with backend: sqlite.
detect.state.retention_days 30 Days of daily aggregates kept. The credential budget counters are kept for their 30-day window whatever this says.
detect.alerts.tag_failures_registered_per_day 5 WARNING when own-issuer tag failures naming a registered asset_seq reach this in a UTC day (guessing or damaged own marks).
detect.alerts.tag_failures_per_day 500 WARNING (a report) when all own-issuer tag failures reach this in a day; includes other deployments on a shared issuer number.
detect.alerts.unconfirmed_per_asset_per_day 100 WARNING when one registered asset gets this many unconfirmed detections in a day (copying, probing or steering), with the number of client buckets.
detect.rate_limit 60/min/ip Per-client-IP sliding window on /detect; IPv6 clients share one bucket per /64, and idle buckets are removed. Behind a reverse proxy start uvicorn with the proxy's address in --forwarded-allow-ips, or every user shares the proxy's bucket.
detect.media null Media families the HTTP /detect route serves, any of image, audio, video, text; others get 415 {"error": "media_not_served", "media": "<family>"}. Unset serves all. Bytes of unknown family still reach detection. For a hosted public checker; the defaults of this and the next three keys leave the sidecar unchanged. May not be an empty list.
detect.max_concurrent null Detections running at once per process on /detect. Unset is no cap. Above it, callers queue (see max_queued). At least 1.
detect.max_queued null Callers allowed to wait for a max_concurrent slot; one more gets 503 {"error": "detect_busy"} with Retry-After: 5. Unset queues without limit. Needs max_concurrent. At least 0.
detect.daily_limit null Detections admitted per process per UTC day (counted after the per-IP limit, the media check and the concurrency check pass); over it, 429 {"error": "daily_limit"} with Retry-After set to the seconds to the next UTC midnight. At least 1.
detect.secondary_validator false Also validate every manifest with a pinned c2patool binary and report validators_disagree (Grading). Needs c2patool_path.
detect.c2patool_path null The c2patool binary of the secondary validator (relative to the config file). It must run at start, or the configuration is refused. Install the c2patool release you want to validate against and give its path here.
detect.c2patool_timeout_s 10 Per-call timeout of the secondary validator. A timeout is an error verdict: a caveat, never a grade change.
detect.trust_anchors_path null PEM bundle of the public C2PA trust list (https://contentcredentials.org/trust/anchors.pem) to recognise other providers' manifests.
detect.own_anchors_path null PEM of your own CA. Defaults to the last certificate in signing.cert_chain_path.
detect.public_url null Public URL of your detection endpoint. Used by the compliance texts in the evidence export (the detection-access text; without it they say "not configured") and, with detect.attest.enabled, as the signed statement's detector_url (must be https).
detect.attest.enabled false Needs a restart. Sign every result /detect serves that names the content's SHA-256 (file and text results alike; the attestation block, Grading) with the active signing key, after the body is built. Needs an installed issuer certificate for watermark.issuer_id and an https detect.public_url, or the server refuses to start. A signing failure serves the body unsigned with the caveat result not signed: <reason>.
detect.attest.timestamp false Not available: true is refused, because the statement carries issued_at and no RFC 3161 token. The statement's issued_at is the signing time.
detect.attest.timeout_ms 2000 How long a detect waits for its signature (100 to 30000). Signing runs in two threads of its own; an AWS KMS key is called with a 1 s connect timeout and one attempt.
detect.cors_origins [] Needs a restart. Origins whose pages may read /detect replies (for example https://laidline.com, so the verify page can ask your checker directly), or ["*"]. POST only, no credentials, Retry-After exposed; the 413 and 429 replies carry the header too. Other routes are never opened. Each entry is scheme://host[:port] with no path.
detect.registry null Keyed mode, images only; needs a restart. The evidence replica of the deployment's own number only, for a public checker that holds the replica but not the marking key, such as laidline.com's box. Read only (sink: {type: file, path} or {type: s3, bucket, prefix, region} with object_lock: false; issuer_ids: only watermark.issuer_id and issuer.previous_ids; any other number is refused at start). Marks of those numbers are confirmed against those records without their payload key (Payload keys, registry-only confirmation; no tag check; logged once at start). Nothing is written to it. S3 needs s3:ListBucket and s3:GetObject on entries/*, index/* and fp/* under the prefix. Needs registry_confirms_without_tag: true. Your numbers plus these may total at most 3.
detect.registry_confirms_without_tag false Needs a restart. With detect.registry: grade B for a mark of a listed issuer when its record matches by bytes or a whole-image fingerprint, with no key tag checked (key_check: not_checked, confirmation_basis: registry). A region never confirms on this path.
detect.fingerprint_max_distance 10 A watermark registry hit counts only when confirmed: the query is the recorded bytes, or its phash64/v1 fingerprint is within this many bits (0 to 64) of the recorded one. On the bench, recompression and resizing move at most 2 bits and unrelated images differ by at least 20. See Grading.

licence and issuer

See Licensing and metering. Two top-level sections of the configuration file; relative paths resolve against the config file's directory, and files left null default to that directory too. No value here can stop or change marking: every outcome is a reported state.

licence:
  # null: licence.d beside the config file; LAIDLINE_LICENCE overrides
  path: null
  clock_skew_s: 300
  status_interval_s: 60
  expose_state_header: false
  auto_fetch: true                    # false: never ask the issuing service for a renewed licence
issuer:
  certificate_path: null              # null: issuer-certificate.json beside the config file
  previous_ids: []
  registration_url: https://issue.laidline.com/v1
  extra_roots_file: null
  usage_report: true
  directory: {path: null, refresh_url: "https://directory.laidline.com/directory.json", stale_after_days: 120}
Key Default Meaning
licence.path null (licence.d beside the config file) Where licence files are read: one file, or every *.json in a directory. The valid file bound to this deployment with the latest not_after decides the state; the others are reported as warnings. LAIDLINE_LICENCE (the licence JSON, base64 on one line) replaces it.
licence.clock_skew_s 300 Tolerance between entry times and TSA times verified against signing.tsa.roots_path before a month is flagged clock_in_doubt. 0 to 86,400 (one day); infinite or NaN values are refused. A flag changes no lock decision. The checkpoint code applies its own 300-second tolerance whatever this key holds.
licence.status_interval_s 60 How often the server re-evaluates the licence state, never on the mark path. Accepted and validated; it has no effect yet.
licence.expose_state_header false Mark and proxy responses never carry the licence state unless this is true (an inference cloud's tenants would otherwise learn the operator's commercial state). Accepted and validated; no response carries the header today.
licence.auto_fetch true Fetch a renewed licence when the installed one is within 7 days of its end or in its grace: at most once per UTC day, from the server and from licence status, usage and evidence export; only a newer licence that verifies and binds this deployment is installed, and a failure is one log line, never a stop. false for an air-gapped host (LAIDLINE_OFFLINE=1 also stops it); renew it with laidline licence install. See licensing.md, "Automatic renewal".
issuer.certificate_path null (issuer-certificate.json beside the config file) The Laidline issuer certificate of a registered number (laidline register and laidline issuer install write it). Absent, corrupt, expired, revoked or mismatched certificates are reported as issuer_state, never refused.
issuer.previous_ids [] The earlier issuer number after a registration (at most one); marks made under it stay this deployment's own.
issuer.registration_url https://issue.laidline.com/v1 The issuing service that laidline register and the usage report talk to.
issuer.extra_roots_file null Extra issuer root keys trusted beside the shipped ones; honoured only under LAIDLINE_ENV=development: a JSON file {"roots": [{key_id, public_key, not_after}]}, the shape of an export's licence/laidline-roots.json. A missing or malformed file adds nothing and logs a warning.
issuer.usage_report true Send the signed, counts-only monthly usage report (laidline usage report) to the issuing service; best effort, marking never waits on it.
issuer.directory.path null A refreshed directory snapshot; null reads directory.json beside the config file, and the one shipped in the package when that is absent. laidline issuer refresh and laidline issuer install write it.
issuer.directory.refresh_url https://directory.laidline.com/directory.json Where laidline issuer refresh fetches the directory; null disables fetching.
issuer.directory.stale_after_days 120 After this many days a snapshot adds a caveat to issuer statements (never an error).

watermark.issuer_id keeps its only rule: an integer 1 to 65,535 when image, audio or video marking is enabled. The range a number falls in (registered 1 to 32,767, trial 32,768 to 65,279, reserved 65,280 to 65,535) is reported, not enforced.

ingest

Used by laidline ingest (aws-s3-sqs).

Key Default Meaning
source s3_sqs s3_sqs (S3 event notifications via SQS), r2_queues (Cloudflare R2 via a Queues HTTP pull consumer) or local (directory poll for development).
queue_url "" SQS queue URL (required for s3_sqs).
dlq_url null SQS queue that receives the original message when an object fails for good.
prefixes ["generated/"] Only keys starting with one of these are processed; others are acked as skipped_filtered.
mime_allowlist [image/png, image/jpeg, image/webp] Content types to mark. The HEAD content type is used when allowed, otherwise the bytes are sniffed. Add audio/wav, audio/flac, audio/mpeg, audio/mp4 (and audio/ogg, audio/aac) to mark audio, video/mp4 and video/quicktime to mark video (with watermark.video.enabled). Aliases such as audio/x-wav or audio/mp3 are read as their canonical types. Objects of any other type are acked as skipped_filtered (reason mime:<type>) with one INFO line, so a missing type looks like a quiet skip.
writeback in_place in_place overwrites the object; prefix:marked/ writes to marked/<key> and leaves the original.
max_bytes 67108864 Larger objects are acked as skipped_filtered (too_large). The worker holds each object in memory, so this is also the video size limit; keep visibility_timeout above the longest mark.
upstream_patterns {} Key prefix to provider name, recorded as upstream in the evidence entry.
max_attempts 5 Redeliveries before a transient failure becomes failed.
wait_seconds 20 SQS long-poll wait. For r2_queues (the Queues pull API returns at once and has no long poll) it is the idle sleep after a pull that returned no messages; a pull that returned only dropped non-create events does not sleep.
visibility_timeout 180 Seconds a message stays hidden while being processed.
batch_size 10 Messages per poll (SQS caps at 10; Queues at 100).
s3.region, s3.endpoint_url null S3 client settings; endpoint_url for R2 (https://<account>.r2.cloudflarestorage.com) or SeaweedFS (http://127.0.0.1:8333 when you run it on the same host).
r2.account_id, r2.queue_id, r2.api_token_env "", "", CLOUDFLARE_API_TOKEN Cloudflare Queues pull consumer; the token comes from the named environment variable.
local.path, local.poll_seconds ./inbox, 1.0 Directory source for development.
gcs.project, gcs.endpoint_url null, null The Cloud Run push receiver (cloud-run): the GCS project, and an emulator endpoint (fake-gcs-server, http://gcslocal:4443; STORAGE_EMULATOR_HOST also works). Credentials come from Application Default Credentials.
checkpoint_per_invocation true AWS Lambda only (lambda): checkpoint the unanchored evidence tail before each invocation returns, as Lambda freezes the timer thread between invocations.

List values can be overridden from the environment with JSON, for example LAIDLINE_INGEST__PREFIXES='["a/","b/"]'.

upstream

Used by the OpenAI-compatible proxy in laidline serve (openai-proxy).

Key Default Meaning
base_url http://localhost:8000 The OpenAI-shaped server the proxy forwards to. A trailing /v1 is stripped.
auth passthrough passthrough forwards the client's Authorization; env replaces it with Bearer $<api_key_env>, read on every request.
api_key_env OPENAI_API_KEY Name of the environment variable holding the upstream key in env mode. The key itself never goes in the file.
flavor openai openai or together: how the proxy asks the upstream for base64 images. openai sends response_format: b64_json; together sends response_format: base64.
timeout_s 120 Upstream timeout; exceeding it is a 504 upstream_timeout.
name null Recorded as upstream in evidence entries. Default: the host name of base_url.

assets

Local store for image responses the proxy returns as url (served at GET /v1/assets/{name}).

Key Default Meaning
store local Only local so far.
path /var/lib/laidline/assets Directory for marked files. Every write deletes any file in it older than the TTL, so use a directory of its own.
ttl_hours 24 Files expire (404) after this; also the Cache-Control max-age.
public_base_url null Base of the returned URLs. Default: the incoming request's base URL, which behind a load balancer is the internal address; set this to the public one.

jobs

The async job path of laidline serve (POST /v1/mark/video, GET /v1/jobs/{id}; video). The store and the worker exist only when watermark.video.enabled is true.

Key Default Meaning
path /var/lib/laidline/jobs SQLite job store (WAL) and the uploaded inputs, created 0700. Local disk only: no NFS or other network filesystem. One replica per directory: a second replica on the same path fails the first one's jobs at its start.
max_queued 16 Jobs waiting beyond the running one; further uploads get 429 jobs_queue_full.
ttl_hours 24 Job records expire after this (404 job_not_found); the marked file follows assets.ttl_hours.
max_upload_bytes 2147483648 Larger uploads get 413 upload_too_large. Starlette spools the whole upload before the handler runs, so this bounds the job directory, not the request: put a body-size limit in your reverse proxy too.
webhook_hosts [] Hosts webhook_url may name. Empty: any https host that is not an IP literal in a private, loopback, link-local or other non-public range; IP literals are checked in every spelling a resolver accepts (127.1, 2130706433, 0x7f000001, a trailing dot, IPv4 embedded in IPv6); a host name that resolves to an internal address is not caught, so list your hosts in production.
webhook_timeout_s 10 Total deadline of the one webhook POST per job (no retry, no signature).

Jobs are local to one process. Status polling needs sticky routing by job id, or a single replica. On Cloud Run, deploy with CPU always allocated (--no-cpu-throttling), or the worker thread stops between requests. At shutdown the running job gets up to 30 s and queued webhooks up to webhook_timeout_s more, about 41 s in all, which is longer than Kubernetes' default 30 s grace period.

adapter

Generic JSON adapter rules for laidline serve: each rule is a route whose upstream response has its images marked in place. Rule fields, kinds, errors and what was proven are in generic-adapter.

Key Default Meaning
rules[] [] name, path, method, upstream, upstream_path, jsonpath, kind (b64, url, data_uri, body), mime_from, on_no_match, max_images, max_response_bytes, fetch_hosts. A bad rule stops serve at start, naming it.
comfyui false Reserved; true is refused (use the LaidlineSaveImage node).

A rule's upstream takes the same keys as the upstream section, flavor included, and defaults to that section.

An empty fetch_hosts allows any host, loopback and link-local addresses included: set it on every url rule.

server

laidline serve binds 127.0.0.1 unless started with --host 0.0.0.0 (the Docker images do that). server.max_upload_bytes (default 67108864, 64 MiB) bounds every request body: mark and detect uploads with room for multipart framing and base64, OpenAI proxy and adapter bodies at the cap itself, and video uploads under jobs.max_upload_bytes instead. Past it the mark, proxy and adapter routes answer 413 upload_too_large and /detect 413. A request whose Content-Length already exceeds it is refused before its body is read; a body sent without a length is cut at the cap as it arrives. server.mark_token_env (default unset) names an environment variable; while that variable holds at least 32 characters, every route except /healthz, /readyz, /metrics, /detect, /v1/assets/* (unguessable capability URLs for end users' browsers) and /admin/* (which has its own token) needs Authorization: Bearer <token> and answers 401 unauthorized without it. The variable is read on every request, so rotating it needs no restart; unset or shorter than 32 characters, the routes are open and start-up logs a warning. The token is removed before a request is proxied, so an upstream.auth: passthrough upstream receives no key: use upstream.auth: env with it. MarkClient sends LAIDLINE_API_KEY as the token. The cap on image size is in media.

media

Limits on the media Laidline decodes.

Key Default Meaning
image.max_pixels 67108864 (8192 x 8192) The most pixels an image may have. A larger one, a decompression bomb included, is refused from its header, before decoding, as image_too_large (400 on /v1/mark/image). Video frames keep watermark.video.max_pixels.

admin

Token-gated routes under /admin on laidline serve: POST /admin/reload (signing keys), GET /admin/evidence/status and GET /admin/evidence/export?from=...&to=...[&sink=N] (Evidence log).

Key Default Meaning
token_env LAIDLINE_ADMIN_TOKEN The environment variable holding the admin token; never the token itself.
export_max_days 31 Longest range one export may cover (400 range_too_large beyond it).
export_max_bytes 2147483648 Largest bundle the route returns (413 bundle_too_large beyond it). The route always includes keyed audio fingerprint records (15,469 bytes per keyed audio mark; Evidence log, "Export bundle"); laidline evidence export --without-records leaves them out.

While the variable is unset or shorter than 32 characters the routes do not exist: every method and path under /admin answers 404, as an unknown path does. With a token set, callers send Authorization: Bearer <token>; any request without the right token, on any /admin path (unknown ones included), is 401. The token is read on every request, so setting or rotating it needs no restart, and it never reaches logs, error bodies, evidence or metric labels. Keep /admin reachable from your own network only.

The export runs inside the serving process, one at a time per process (409 export_in_progress; several processes or replicas can each run one). The bundle is built in memory and in a temporary directory before its size is checked, so memory use is bounded by the range, not by export_max_bytes: size export_max_days to your volume, or export large ranges with laidline evidence export.

Reference

  • Quickstart: a first deployment with the starter file
  • Production: the settings to change before real traffic
  • Command line: laidline init, serve, ingest and the rest
  • HTTP API: the routes that server, admin, jobs and detect configure
  • Licensing: the licence and issuer sections in context
  • Third-party licences: the models and libraries Laidline runs