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 initwrites a starter file; the sections it leaves out take their defaults. An unknown top-level section stops the load.- An unset
LAIDLINE_ENVmeansproduction: every relaxed check needsLAIDLINE_ENV=developmentexactly. laidline servebinds127.0.0.1;server.max_upload_bytes(64 MiB) caps every request body andserver.mark_token_envadds 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_totalandlaidline_evidence_replica_dropped_total, and logged once per overflow episode; - at shutdown
close()drains for up toreplica_drain_timeout_s; entries still queued then are counted inlaidline_evidence_sink_failures_totalonly (laidline_evidence_replica_dropped_totalcounts full-queue drops). A crash or SIGKILL beforeclose()loses whatever is queued, without counting it; verifyon 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,ingestand the rest - HTTP API: the routes that
server,admin,jobsanddetectconfigure - Licensing: the
licenceandissuersections in context - Third-party licences: the models and libraries Laidline runs