Changelog
All notable changes to Laidline. The format follows Keep a Changelog.
Unreleased
Fixed
laidline registerwithout--display-namestops before anything is signed or sent and says what the flag needs: a name that contains your domain's label (--display-name "Acme Inc"foryou@acme.com). The issuer refused the request withdisplay_name is required, which named neither the flag nor the rule.laidline registerrefusesExample Org, the placeholderlaidline initwrites when--orgis not given, as the legal name unless--nameis passed.laidline markandlaidline detecton 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_idvalues keep making private marks after a registration: the certificate's number and the trial numbers it lists as its prior numbers. - Every
laidline registerexample in the docs and in thelaidline initclosing line carries--display-name, and the examples that runinitfirst 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_VERSIONis2026-11-01:laidline registernow 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. Versions2026-10-25and2026-10-31stay 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_NOTICESnames 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(onoroff, defaultoff): 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_SEGMENTSstays 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 unlessLAIDLINE_OFFLINE=1, and falls back to torch with a warning when it cannot;deploy/docker/check-image.shchecks a built image carries it.laidline models fetchdoes 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, andlaidline models fetchnow prints the host each file came from.- The documents proof:
laidline register,laidline licence fetchand automatic renewal fetch an issued registration's documents with aLaidline-Proofheader, 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 (runlaidline registeragain) 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,usageandevidence exportfetch 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: falseturns it off (docs/licensing.md, "Automatic renewal"). laidline register --interval month|year(defaultmonth): how a paid plan is billed. Every registration body now carriesinterval(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
88a993b1cc6b3d35under 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_invalidnames it). watermark.video.encoder(defaultlibx264) selects the H.264 encoder of the video re-encode:libopenh264orh264_nvenc, withvideo.crfmapped approximately to each one's quality setting andwatermark.video.encoder_optionslaid over it. A non-default encoder is trial-encoded at start-up (video_encoder_unavailableotherwise), and an option name the encoder does not take isencode_failedinstead 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: falseand noissuerblock, 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 theclaimed issuer Nsentence intier_caveats. The public tier shows a payload and anissuerblock only at grade B; every other present keyed signal ends itscaveatswith "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 keyedpayload.layout: nonedeployment 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 atp < 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_searchis 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_sis 8.0 (was 9.0). A record carriesextra.payload_read.phase_searchonly 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_cutoveris removed with the keyed-mode legacy CRC path; a configuration written by 0.1.xlaidline initcarrieslegacy_cutover: nulland 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
laidlineinto the newlaidline-benchpackage (laidline_benchreplaceslaidline.bench).laidline benchneeds it: without it the command exits with status 2 and printslaidline bench needs the laidline-bench package: pip install laidline-bench. - Verified TSA times now feed the licence clock and
late_anchoringon every deployment whose config leavessigning.tsa.roots_pathunset or sets it tobuiltin. The server built its timestamp client without the config directory, so a configuredsigning.tsa.roots_pathnever loaded; it now does.signing.tsa.roots_pathdefaults tobuiltin, 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 andnullor an empty value turns verification off. Evidence bundles carry the roots in use ascerts/tsa-roots.pemby default. The usage statement counts the roots as configured only when a TSA is configured too. The literalbuiltincovers onlytimestamp.digicert.comandtimestamp.sectigo.com: with any other TSA host insigning.tsa.urlorfallbacksit loads no roots, bundles none, counts as not configured in the usage statement and logs one start-up warning, so setroots_pathto that TSA's published roots. Upgrade note: a deployment written by an earlierlaidline init(or copied from the earlierconfig.example.yaml) hasroots_path: nullwritten explicitly, which keeps verification off. Change that line toroots_path: builtinor delete it (and set your own roots if you use another TSA). - The usage statement's
closedno 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'sclockstayscustomer_onlythen. A roots file is bundled into exports only while a TSA URL is configured: withurl: nulland no fallbacks nocerts/tsa-roots.pemis 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_free1,200 credits a month,lite15,000,starter150,000,growth1,000,000,scale10,000,000, thenenterprise(suggested_bandin the usage statement andlicence status). Licences under the earlier ids stay valid:registeredshows as Starter,unlimitedandplatformas Enterprise;licence status --jsonaddslicence_plan, the id on the licence. - The
domain_already_registeredhint names every plan that binds more than one key:--plan starter,growthorscale(Starter binds 2 keys). laidline servebinds127.0.0.1by default (it bound0.0.0.0) and logslistening 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_ENVmeansproductioneverywhere:LaidlineConfig.laidline_envnow returnsproductionwhen the variable is unset (it returneddevelopment), matching the payload-key and issuer-root checks, which already relaxed only for an explicitLAIDLINE_ENV=development. /readyz'serroris 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 (413upload_too_large;/detectkeeps its own 413 shape). A body whoseContent-Lengthis 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_envnaming a variable that holds at least 32 characters, the mark, job, adapter and OpenAI proxy routes needAuthorization: Bearer <token>(401unauthorizedotherwise);/healthz,/readyz,/metrics,/detectand/v1/assets/*(unguessable capability URLs for browsers) stay open.MarkClientsendsLAIDLINE_API_KEYas that token. The token is never forwarded upstream. - Outbound address guard: job webhooks and the adapter's
urlimage 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, minusNO_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 adapterurlrule withoutfetch_hostslogsadapter 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'sMAX_IMAGE_PIXELS): a larger image, a decompression bomb included, is refused from its header asimage_too_large(400 on/v1/mark/image). Video keepswatermark.video.max_pixels. - Text-detection tokenizers load with
trust_remote_code=False, and a pinned Hub id (tokenizers/<id>inmodels/pins.json, Qwen/Qwen2.5-0.5B-Instruct now) loads at its pinned revision. laidline keys rotate --admin-urlrefuses plainhttp://to any host butlocalhost,127.0.0.1and::1: the admin token would cross the network unencrypted.laidline initsays 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 aboveissuer.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 reasonssubscription_exists,subscription_upgrade_expiredandsubscription_upgrade_revertedprint what to do next.laidline register/licence fetch: a review row that carriespayment_urlprints "open invoice: <link>", and a serverhintis 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
/detectandlaidline detectsay "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 withheldexits 2). - Signed detect results: with
detect.attest.enabled, every result that names its content hash (file and text) carries anattestationblock (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_originslets listed origins read/detectreplies.laidline detect FILE --confirmasks the issuer's checker and verifies its reply;laidline verify-result RESULT.json --file FILEchecks a saved one. detect.registrywithdetect.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 registerandlaidline licence fetchhandle a registration that upgrades an existing Stripe subscription in place (payment: subscription_upgrade, nocheckout_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_appnow callslaidline.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 setsAWS_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 registerstops waiting when the registration goes torevieworpayment_pendingafter the email link is clicked, printing the reason (with a hint fordomain_already_registered,daily_capandmanual_payments) and the resume command instead ofwaiting for the link to be clickeduntil the timeout;laidline licence fetchprints each state change once while it waits.laidline licence fetchstops on review likeregisterdoes, and a duplicate-domain registration on a one-key plan is denied with the copy-identity hint.laidline markagainst a timestamp server that does not answer printserror: tsa_unavailable: …and a hint (signing.tsa.required: falseor a reachablesigning.tsa.url;LAIDLINE_OFFLINE=1only forbids model downloads) and exits 1, instead of a traceback./readyzre-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 statusno longer saysregistered Nwhen the configuredwatermark.issuer_idis neither the installed certificate's number nor one of its prior trial numbers: it printsissuer: unregistered shared number M (installed certificate for N not used: number_mismatch)andset watermark.issuer_id: N in laidline.yaml and reload; marks stay shared until then, and--jsonreports"error": "number_mismatch"with"certificate_issuer_id": N.servelogs an issuer certificate it does not use for marking at WARNING, not INFO.POST /detecton a keyed deployment (the public tier, whichlaidline initsets up) now answersmarked_by_this_systemat grade B for a text watermark present over at leastmin_tokenstokens, aslaidline detectdoes for the same text; it answeredsignals_present_unverifiedbecause the public tier's regrade had no text case. A text watermark over fewer tokens stayssignals_present_unverifiedat grade D on every tier.- An audio-only install (
laidline[audio], and solaidline-livekitandlaidline-pipecat) imports: theaudioextra now carries Pillow, whichlaidline.core.apiimports at module level. - A text-only install (
pip install laidline-transformers "laidline[text]") imports: thetextextra now carries Pillow andc2pa-python, whichlaidline.core.apiandlaidline.c2pa.signimport at module level (theaudioextra got Pillow above), and a packaging test pins both fortext,audioandimagein the extras anduv.lock. - The text watermark core is built on the generating model's device (
device=onMarker.text_logits_processorandMarker.text_core;laidline-transformerspassesstr(model.device)). Before, CUDA generation with the transformers helper failed with a cuda/cpu tensor mismatch;laidline-transformerstherefore needslaidline>=0.1.1. laidline[image,ingest]runslaidline ingest: theingestextra now carriesprometheus-client, whichlaidline.ingest.workerimports at module level (it failed withModuleNotFoundError: prometheus_client; found in the live Cloudflare run), and a packaging test pins it in the extra anduv.lock.- The R2 queue source sleeps
ingest.wait_secondsafter an empty pull (the Queues pull API does not long-poll), solaidline ingestno longer spins on the Cloudflare API rate limit;wait_secondsis documented as SQS long-poll wait and R2 idle sleep. Ther2_queuesdocstring now says pull bodies arrive as plain JSON strings. laidline-transformershas a full package description (it was cut off at "Helper for `model.").- Plugin packages
laidline-comfyui,-diffusers,-livekit,-pipecat,-transformersand-vllmmove to 0.0.2 because the rebuilt plugin wheels carry new contents and newlaidlinefloors, so they need a new version.
0.1.0 - 2026-10-18
Added
-
The two production Laidline root keys are pinned: root A
71541a379b752801and root Bc60506fddbdb4685(both valid until 2036-07-28), inlaidline/trust/roots.pyandverify.py. -
The shipped issuer directory is the first production snapshot, signed by the directory issuing key
c24f4a81f5625273, carrying the shared pool key labelledpool-2026. -
Self-test marks (
MarkContext(selftest=True)) are counted per month and shown asself-test marks: Ninlaidline licence status, the usage statement and report, the export's coverage lines andverify.py; the verifier treats a hidden count aslicence_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.comanddirectory.laidline.com.