Changelog

All notable changes to Laidline. The format follows Keep a Changelog.

Unreleased

Fixed

  • laidline register without --display-name stops before anything is signed or sent and says what the flag needs: a name that contains your domain's label (--display-name "Acme Inc" for you@acme.com). The issuer refused the request with display_name is required, which named neither the flag nor the rule.
  • laidline register refuses Example Org, the placeholder laidline init writes when --org is not given, as the legal name unless --name is passed.
  • laidline mark and laidline detect on an install without the image extra print one line naming the extra (pip install "laidline[image]") instead of a traceback.
  • Licensing and metering says which watermark.issuer_id values keep making private marks after a registration: the certificate's number and the trial numbers it lists as its prior numbers.
  • Every laidline register example in the docs and in the laidline init closing line carries --display-name, and the examples that run init first pass --org, so the printed commands work as written. The docs name the version as 1.0.1.

1.0.1 - 2026-11-01

Changed

  • TERMS_VERSION is 2026-11-01: laidline register now asks for the Customer Licence Agreement of that date, which names the current plans, adds automatic renewal, cancellation at the end of the paid period, plan changes and the True-Up dates, and states that the names and domains of registered numbers are always published. It also waives a True-Up under 50 cents, holds a True-Up when a balance is not zero, and lets Laidline charge one held for a credit as a card payment with no invoice. Versions 2026-10-25 and 2026-10-31 stay published unchanged at their own addresses.

Fixed

  • The compliance texts no longer claim a manifest for text-only systems; the "Legal texts checked on" date is 31 October 2026, after every quoted source was re-read.
  • THIRD_PARTY_NOTICES names PyAV and FFmpeg by reference.

1.0.0 - 2026-10-31

First public release on PyPI. The six integration packages (laidline-comfyui, laidline-diffusers, laidline-livekit, laidline-pipecat, laidline-transformers, laidline-vllm) and the new laidline-bench ship at 1.0.0 beside it and depend on laidline>=1.0,<2.

Added

  • watermark.audio.payload.phase_search (on or off, default off): the keyless search for a keyed audio mark that does not start on a 20 ms boundary of the clip (a delayed or trimmed recording, a codec's lag), under the hardened decode. It is off because the pre-registered v2 voice matrix (2026-10-30, analysis-voicev/summary.json; 19,850 units) had it choose a keyless payload on 2 of 13,000 unmarked units (2 of 26,000 pooled with the earlier run; 0 tag accepts) where the line allowed none, and the detect time ran over budget in 5 of 74 cells (pooled count) on L4 boxes. Switched on it reads a 9.5 s off-grid excerpt 99 of 100 (6 off), a delayed 10 s clip 96 (0), G.722 98 (0), AMR-WB 100 (0) and AMR-NB 91 (0), for about +0.5 s per read at p50 on an A10G and +0.8 to 1.2 s on an L4. The false-accept bound still states the worst case with the search's words. SEARCH_SEGMENTS stays 0 (the S0 round of 2026-10-28).
  • The TrustMark Q decoder's ONNX graph is a downloadable pin (it downloads from the Laidline mirror, https://laidline.com/models/trustmark/decoder_Q.fp32.onnx, and is checked against the pinned sha256): the keyframe reader fetches it on first use unless LAIDLINE_OFFLINE=1, and falls back to torch with a warning when it cannot; deploy/docker/check-image.sh checks a built image carries it. laidline models fetch does not fetch the graph yet (docs/install.md, docs/cli.md).
  • LAIDLINE_MODELS_MIRROR: an https base URL of a Laidline mirror from which the AudioSeal, VideoSeal and PixelSeal weights are fetched first, with the pinned upstream URL as the fallback when the mirror is unreachable or serves a file that does not match. The pinned SHA-256 and size still decide what is kept, and laidline models fetch now prints the host each file came from.
  • The documents proof: laidline register, laidline licence fetch and automatic renewal fetch an issued registration's documents with a Laidline-Proof header, a short statement (registration, issuing service, time, nonce) signed by the deployment's active signing key, new for every fetch; the status polls carry none. The issuing service hands the documents only to a key the registration binds, so a rotated key must be bound (run laidline register again) before it can fetch. An answer of "documents": "proof_required" is one error line (docs/licensing.md, "Licence files"). Nothing secret is stored beside the licence files.
  • Automatic licence renewal: when the installed licence is within 7 days of its end or in its grace, the server (at start and daily) and licence status, usage and evidence export fetch the renewed licence from the recorded registration, at most once per UTC day, and install it only when it verifies, binds the deployment and is newer. A failure is one log line; marking never waits. licence.auto_fetch: false turns it off (docs/licensing.md, "Automatic renewal").
  • laidline register --interval month|year (default month): how a paid plan is billed. Every registration body now carries interval (the issuing service reads a body without it as yearly); the free plan accepts it and ignores it.
  • The production weight table (version 1.0.1) ships in the package, signed by the issuing key 88a993b1cc6b3d35 under the pinned production roots, in force from 2026-11 (image 1 a mark, video 0.2 a second, audio 0.05 a second, text 0.05 per 1,000 tokens; free allowance 1,200 a month; withhold_paperwork). From November 2026 the cap, the key switch and the paperwork lock operate on production deployments; a development weight table ships beside it and is refused on production deployments (weights_invalid names it).
  • watermark.video.encoder (default libx264) selects the H.264 encoder of the video re-encode: libopenh264 or h264_nvenc, with video.crf mapped approximately to each one's quality setting and watermark.video.encoder_options laid over it. A non-default encoder is trial-encoded at start-up (video_encoder_unavailable otherwise), and an option name the encoder does not take is encode_failed instead of being ignored. An install whose FFmpeg and PyAV have no GPL parts (conda-forge's lgpl FFmpeg variant with OpenH264) is documented in docs/integrations/video.md, "Encoding without x264", and docs/licences.md states what the PyPI PyAV wheel bundles. Every published video figure stays a libx264 figure.

Changed

  • An unkeyed deployment's audio detect reports presence only. The 16-bit AudioSeal word has no error detection (one flipped bit renamed the issuer at full confidence on 40 of 100 marked music clips in the 2026-10 null campaign), so the signal is grade D with payload: null, issuer_match: false and no issuer block, whatever the word reads, and carries the caveat "an unkeyed audio mark carries a 16-bit word with no error detection; this deployment reports presence only and names no issuer". Unkeyed audio no longer grades C. Unkeyed image and video words are unchanged; keyed deployments are unchanged (docs/grading.md, docs/keys.md).
  • Keyed signals name an issuer only when their tag verified (or a registry-only issuer's records confirmed it). A payload whose tag failed, was not checked or never reached a lookup is no longer printed on any tier, with its candidate words, since one flipped bit or a phantom can write any issuer number; the full tier keeps presence, grade, status and tag, and a verified, non-B payload keeps the claimed issuer N sentence in tier_caveats. The public tier shows a payload and an issuer block only at grade B; every other present keyed signal ends its caveats with "a mark is present; its issuer could not be read", whatever the tag outcome (a failed tag, a verified unregistered value and a copied mark give one public body). The 16-bit AudioSeal read in a keyed payload.layout: none deployment follows the same public rule. An unkeyed deployment still serves every caller the full result, 16-bit issuer word included; whether that changes is undecided. Documents the 2026-10 null campaign finding that 40 % of marked music clips read as another issuer's 16-bit word at full confidence.
  • Keyed P70 audio: the streaming detector card's detection alone is no longer public presence (the full tier keeps it, with a caveat). Laidline's keyed marks are written by the batch generator, and on unmarked audio that card gave 360 of the 361 public false presences on music, all 25 on read speech and 177 of the 192 on sounds. Replayed on the saved campaign records (keyed read, analysis-audio/regate/regate.md): music 361 to 2 of 4,979, read speech 25 to 0, sounds 192 to 15 of 5,000, with 0 positives losing public presence.
  • Text grade B needs the reported test's p below the new watermark.text.grade_b_p_value (default 1e-5), and KGW's fast test uses the exact binomial tail like its fallback; a text at p < detect_p_value (1e-3) that misses the gate is grade D. At 1e-3 the campaign found grade B on 19 of 20,000 SynthID and 32 of 20,000 KGW human documents. Re-scored 2026-10-30 (analysis-textv/headline.txt): 0 of 20,000 per scheme at 1e-5, KGW presence 32 of 20,000 (13 of 5,000 at 1,024 tokens, upper bound 0.44 %), no marked positive lost; grade B now needs about 1.9 times the tokens for the same per-token signal, which the text robustness bench has not yet measured. The measured fast-test false-positive rates in docs/grading.md and docs/config.md were taken on the normal tail, before 2026-10-28.
  • Voice-call documentation from the 2026-10 voice-call matrix (docs/integrations/audio.md, "Voice calls"; docs/benchmarks.md; docs/robustness.md): payload floor 8.0 s delivered on the 20 ms grid (a stream fed 10 s delivers 9.5 s; the claim stays 10 s), presence from 1.5 s, the codec rows (G.711, G.726, Opus from 12 kbit/s and the MP3 twin read; Opus 8 kbit/s, Speex and AMR-NB do not; G.722 and AMR-WB are delay-limited), packet loss to 10 %, white noise (half the payloads lost at about 22 dB SNR), babble, reverb, chunk size, latency and session rate. Off-grid audio keeps presence and loses the payload unless payload.phase_search is switched on (off by default; measured in the v2 matrix). Keyed music reads the issuer payload on 64 % of marked 30 s clips and does not support deep scanning; an unedited copy of a registered 30 s or 120 s asset is reported as containing a registered region.
  • Keyed audio payload read: behind payload.phase_search (off by default), under the hardened decode, when the batch card detects and nothing is chosen, a keyless phase search (PH2 on the batch card; its two best picks minus phase 0 and the span read's phases, so 0 to 2 phases, 3 grids each) finds a mark that does not start on a 320-sample boundary (a delayed or trimmed clip, a codec's lag). The false-accept bound's worst call grows from 344 to 392 words (464 with the span read), 8.99e-13 per detected clip (1.065e-12). payload_readable_from_s is 8.0 (was 9.0). A record carries extra.payload_read.phase_search only when the search ran.
  • The keyed-tag magic reads LLTAG: keyed marks made by 0.1.x no longer pass the tag check (no 0.1.x marks exist outside development).
  • payload_keys.legacy_cutover is removed with the keyed-mode legacy CRC path; a configuration written by 0.1.x laidline init carries legacy_cutover: null and is refused until the line is deleted. A keyed deployment still reads a CRC-format mark from an unkeyed deployment and shows its claimed issuer at grade D.
  • The benchmark code moved out of laidline into the new laidline-bench package (laidline_bench replaces laidline.bench). laidline bench needs it: without it the command exits with status 2 and prints laidline bench needs the laidline-bench package: pip install laidline-bench.
  • Verified TSA times now feed the licence clock and late_anchoring on every deployment whose config leaves signing.tsa.roots_path unset or sets it to builtin. The server built its timestamp client without the config directory, so a configured signing.tsa.roots_path never loaded; it now does. signing.tsa.roots_path defaults to builtin, the public DigiCert Trusted Root G4 and Sectigo Public Time Stamping Root R46 shipped in the package (evidence/data/tsa-roots-public.pem); a path overrides them and null or an empty value turns verification off. Evidence bundles carry the roots in use as certs/tsa-roots.pem by default. The usage statement counts the roots as configured only when a TSA is configured too. The literal builtin covers only timestamp.digicert.com and timestamp.sectigo.com: with any other TSA host in signing.tsa.url or fallbacks it loads no roots, bundles none, counts as not configured in the usage statement and logs one start-up warning, so set roots_path to that TSA's published roots. Upgrade note: a deployment written by an earlier laidline init (or copied from the earlier config.example.yaml) has roots_path: null written explicitly, which keeps verification off. Change that line to roots_path: builtin or delete it (and set your own roots if you use another TSA).
  • The usage statement's closed no longer needs a verified TSA token: a finished month with every stream covered closes, and only a covering checkpoint whose token was checked against the roots in force and failed (verified_against_roots: false) blocks it. A checkpoint with no token (TSA outage, blocked egress) or a token recorded before this release has no verdict and does not block; the statement's clock stays customer_only then. A roots file is bundled into exports only while a TSA URL is configured: with url: null and no fallbacks no certs/tsa-roots.pem is written, where earlier releases bundled a customer file regardless.
  • The free allowance is 1,200 credits a month, for the no-sign-up cap and the registered Free plan alike.
  • The plan bands follow the published price ladder: registered_free 1,200 credits a month, lite 15,000, starter 150,000, growth 1,000,000, scale 10,000,000, then enterprise (suggested_band in the usage statement and licence status). Licences under the earlier ids stay valid: registered shows as Starter, unlimited and platform as Enterprise; licence status --json adds licence_plan, the id on the licence.
  • The domain_already_registered hint names every plan that binds more than one key: --plan starter, growth or scale (Starter binds 2 keys).
  • laidline serve binds 127.0.0.1 by default (it bound 0.0.0.0) and logs listening on <host>:<port> (loopback only|all interfaces) at start-up. Migration note: a host that serves other machines directly passes --host 0.0.0.0; the Docker images' entrypoint already does, so containers are unchanged.
  • An unset LAIDLINE_ENV means production everywhere: LaidlineConfig.laidline_env now returns production when the variable is unset (it returned development), matching the payload-key and issuer-root checks, which already relaxed only for an explicit LAIDLINE_ENV=development.
  • /readyz's error is a short category and at most an exception class name (tsa_unavailable: MarkError, self_test_failed: <class>, startup_failed); the full message goes to the log only.

Security

  • Upload cap: server.max_upload_bytes (default 64 MiB) bounds every request body: mark and detect uploads (with a framing allowance for multipart), proxy and adapter bodies (413 upload_too_large; /detect keeps its own 413 shape). A body whose Content-Length is over the cap is refused before it is read; a body sent without a length is cut at the cap as it arrives. Video uploads keep their own cap, jobs.max_upload_bytes.
  • Optional inbound token: with server.mark_token_env naming a variable that holds at least 32 characters, the mark, job, adapter and OpenAI proxy routes need Authorization: Bearer <token> (401 unauthorized otherwise); /healthz, /readyz, /metrics, /detect and /v1/assets/* (unguessable capability URLs for browsers) stay open. MarkClient sends LAIDLINE_API_KEY as that token. The token is never forwarded upstream.
  • Outbound address guard: job webhooks and the adapter's url image fetch resolve the host and refuse to connect when any address is loopback, private, link-local, multicast, reserved or unspecified, and connect to the checked address (no second lookup). When the environment names a proxy for the target (HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, minus NO_PROXY), webhooks and adapter fetches go through it and the proxy resolves the host, so the guard cannot apply; start-up logs one warning that adapter fetches go through the proxy unguarded. An adapter url rule without fetch_hosts logs adapter fetch allows any public host.
  • Image decoding is capped by media.image.max_pixels (default 67,108,864, 8192 x 8192, so 4K and 8K upscales pass; Pillow's MAX_IMAGE_PIXELS): a larger image, a decompression bomb included, is refused from its header as image_too_large (400 on /v1/mark/image). Video keeps watermark.video.max_pixels.
  • Text-detection tokenizers load with trust_remote_code=False, and a pinned Hub id (tokenizers/<id> in models/pins.json, Qwen/Qwen2.5-0.5B-Instruct now) loads at its pinned revision.
  • laidline keys rotate --admin-url refuses plain http:// to any host but localhost, 127.0.0.1 and ::1: the admin token would cross the network unencrypted.
  • laidline init says that one usage report a month (counts only, no media) goes to the issuer and how to turn it off, and the written configuration carries the same comment above issuer.usage_report.
  • SECURITY.md: how to report a vulnerability, supported versions, scope and deployment guidance.

0.1.1 - 2026-10-21

Added

  • laidline register / licence fetch: a subscription upgrade the customer must pay themselves prints the Stripe invoice link ("pay the prorated invoice at ..."), and review reasons subscription_exists, subscription_upgrade_expired and subscription_upgrade_reverted print what to do next.
  • laidline register / licence fetch: a review row that carries payment_url prints "open invoice: <link>", and a server hint is printed when the CLI has no hint of its own for the reason (the CLI's wording wins otherwise).
  • Public attribution: a valid C2PA manifest's signing key is compared with the issuer directory, so /detect and laidline detect say "signed by registered issuer N, watermark agrees", "signed by the certificate registered as issuer M" or that the Content Credentials and the watermark disagree. Directory entries carry the operator's domain, dated names and the checker probe; registration always publishes the name (laidline register --display withheld exits 2).
  • Signed detect results: with detect.attest.enabled, every result that names its content hash (file and text) carries an attestation block (ES256 over an RFC 8785 statement with the content hash, detector URL and time), signed after the body is built and never failing a detect. detect.cors_origins lets listed origins read /detect replies. laidline detect FILE --confirm asks the issuer's checker and verifies its reply; laidline verify-result RESULT.json --file FILE checks a saved one.
  • detect.registry with detect.registry_confirms_without_tag: confirm another issuer's image marks against its records, read only, without its payload key (key_check: not_checked, confirmation_basis: registry). Listing the deployment's own number makes its own marks registry-only too.

Changed

  • laidline register and laidline licence fetch handle a registration that upgrades an existing Stripe subscription in place (payment: subscription_upgrade, no checkout_url): they say the licence issues when the prorated invoice is paid and print the resume command, instead of a checkout link.
  • Issuer lines read "claimed by issuer N, unconfirmed (anyone can write this number)" and "confirmed by issuer N (name, operator of domain)"; the "full verification at" tail is gone (grading.md, issuer lines).
  • Shipped issuer directory snapshot: serial 5 (2026-10-20), lists issuer 1.
  • The Cloud Run app logs ingest outcomes at INFO: create_app now calls laidline.core.logs, so a deployment's log shows each outcome.
  • Docker image builds first stage and verify the pinned TrustMark decoder graph.
  • The Lambda example builds without attestations (--provenance=false --sbom=false) and sets AWS_LAMBDA_LOG_LEVEL=INFO.

Documentation

  • docs/legal/compliance-statements.md: the detection-URL sentence no longer promises "full verification at"; the verify-tool paragraph says the verifier offers a routed confirmation from the issuer's own checker on request and never relays the file; the Limits bullet says registration always publishes the display name and verified domain.

Fixed

  • laidline register stops waiting when the registration goes to review or payment_pending after the email link is clicked, printing the reason (with a hint for domain_already_registered, daily_cap and manual_payments) and the resume command instead of waiting for the link to be clicked until the timeout; laidline licence fetch prints each state change once while it waits.
  • laidline licence fetch stops on review like register does, and a duplicate-domain registration on a one-key plan is denied with the copy-identity hint.
  • laidline mark against a timestamp server that does not answer prints error: tsa_unavailable: … and a hint (signing.tsa.required: false or a reachable signing.tsa.url; LAIDLINE_OFFLINE=1 only forbids model downloads) and exits 1, instead of a traceback. /readyz re-runs the start-up self-test every 30 s while not ready, backing off to 15 minutes while it keeps failing, so it turns 200 once the TSA answers again instead of staying 503 until a restart. A self-test that fails no longer uses up a place in the daily self-test cap, so the retries stay out of the customer's counts.
  • laidline licence status no longer says registered N when the configured watermark.issuer_id is neither the installed certificate's number nor one of its prior trial numbers: it prints issuer: unregistered shared number M (installed certificate for N not used: number_mismatch) and set watermark.issuer_id: N in laidline.yaml and reload; marks stay shared until then, and --json reports "error": "number_mismatch" with "certificate_issuer_id": N. serve logs an issuer certificate it does not use for marking at WARNING, not INFO.
  • POST /detect on a keyed deployment (the public tier, which laidline init sets up) now answers marked_by_this_system at grade B for a text watermark present over at least min_tokens tokens, as laidline detect does for the same text; it answered signals_present_unverified because the public tier's regrade had no text case. A text watermark over fewer tokens stays signals_present_unverified at grade D on every tier.
  • An audio-only install (laidline[audio], and so laidline-livekit and laidline-pipecat) imports: the audio extra now carries Pillow, which laidline.core.api imports at module level.
  • A text-only install (pip install laidline-transformers "laidline[text]") imports: the text extra now carries Pillow and c2pa-python, which laidline.core.api and laidline.c2pa.sign import at module level (the audio extra got Pillow above), and a packaging test pins both for text, audio and image in the extras and uv.lock.
  • The text watermark core is built on the generating model's device (device= on Marker.text_logits_processor and Marker.text_core; laidline-transformers passes str(model.device)). Before, CUDA generation with the transformers helper failed with a cuda/cpu tensor mismatch; laidline-transformers therefore needs laidline>=0.1.1.
  • laidline[image,ingest] runs laidline ingest: the ingest extra now carries prometheus-client, which laidline.ingest.worker imports at module level (it failed with ModuleNotFoundError: prometheus_client; found in the live Cloudflare run), and a packaging test pins it in the extra and uv.lock.
  • The R2 queue source sleeps ingest.wait_seconds after an empty pull (the Queues pull API does not long-poll), so laidline ingest no longer spins on the Cloudflare API rate limit; wait_seconds is documented as SQS long-poll wait and R2 idle sleep. The r2_queues docstring now says pull bodies arrive as plain JSON strings.
  • laidline-transformers has a full package description (it was cut off at "Helper for `model.").
  • Plugin packages laidline-comfyui, -diffusers, -livekit, -pipecat, -transformers and -vllm move to 0.0.2 because the rebuilt plugin wheels carry new contents and new laidline floors, so they need a new version.

0.1.0 - 2026-10-18

Added

  • The two production Laidline root keys are pinned: root A 71541a379b752801 and root B c60506fddbdb4685 (both valid until 2036-07-28), in laidline/trust/roots.py and verify.py.

  • The shipped issuer directory is the first production snapshot, signed by the directory issuing key c24f4a81f5625273, carrying the shared pool key labelled pool-2026.

  • Self-test marks (MarkContext(selftest=True)) are counted per month and shown as self-test marks: N in laidline licence status, the usage statement and report, the export's coverage lines and verify.py; the verifier treats a hidden count as licence_months_mismatch.

Changed

  • Self-test marks are capped at 10 per UTC day per deployment; beyond that a self-test mark is an ordinary, metered mark and the result carries a warning. The server's start-up self-test is unaffected.
  • The hourly reload keeps the last good mark inputs when the certificate or licence cannot be read (a transient read error no longer turns private marks shared for an hour).

Notes

  • The weight table in the package is a development table, refused by the production roots.
  • Defaults already point at issue.laidline.com and directory.laidline.com.