Payload keys
Read this if you run Laidline in keyed mode, or you need to know what a payload key protects and what it does not. You will learn the two key families, how to generate, configure and rotate keys, what detect shows each caller, and the measured limits per medium. Skip if you only want a first mark (Quickstart). Time: 30 minutes to read. Needs: a deployment with
watermark.payload_keysset (Configuration).
In short
- A payload key adds a truncated HMAC-SHA-256 tag to every image, video and audio payload, so your detect can tell a payload you made from one somebody wrote.
laidline initwrites a private key; it marks nothing untillaidline registerbinds it to your name. Until then marks carry the shared pool key.- A key does not stop copying, removal or imitation; the section "What a key does not do" lists what it leaves open.
- Rotate by adding the new key as
activeand setting the old one toverify; retire only afterlaidline payload-keys retire. /detectserves a public response tier to callers without credentials and the full tier to credentialed callers.
Laidline's watermark payloads name an issuer and an asset. Without a key anyone with the public model weights can write a payload that names your issuer number. Payload keys add a short keyed tag (a truncated HMAC-SHA-256) to each image, video and audio payload so that your own detect can tell a payload you made from one somebody wrote.
Status. The configuration, the keys, the key commands, the response tiers and the detect-state
store are built, and images, video and audio are keyed: in keyed mode a PixelSeal image carries
pixelseal-i77-r1, a TrustMark image trustmark-i61-r1, a VideoSeal video videoseal-v92-r1, a keyframe-marked video
trustmark-kf61-r1 and an AudioSeal audio file or stream audioseal-p70-r1 (below, "Images", "Video" and "Audio"),
and detect checks one tag per call under every live key of the
medium, with the equal-work registry lookup, the detect-state record and the credential budgets. laidline init
writes the watermark.payload_keys section enabled, with a payload-k1.key secret file (mode 0600) beside the
configuration: an initialised deployment is keyed. That key is the private key: it marks nothing until
laidline register binds it to your name. Until then new marks carry the shared pool key of the signed directory
(The shared family and the private key).
What keyed mode changes:
- image, video and audio marks carry a keyed payload and detect grades it (below, "Images", "Video" and "Audio");
/detectserves the public response tier to callers without credentials (below);- the public tier shows only the presence of an audio watermark (below, "Audio");
- key events are recorded in the evidence chain;
- an S3 evidence sink writes ten decoy objects (five pointers, five entries), and a Postgres schema stores five decoy rows (below).
What a key does
- It stops fresh forgery and modification of image and video payloads: a payload this deployment never tagged is accepted with probability K x 2^-t per detect call that reaches the key (K live keys of the medium, t tag bits). Detect checks at most one payload per call, under every live key, whatever the file holds.
- It makes "issued for another asset" evidence of copying rather than coincidence or fabrication.
- It separates two deployments that share an issuer number: their tags fail each other's keys.
What a key does not do
- Copying. A genuine payload copied onto other content carries a valid tag. Decoding and re-embedding with the public models is exact and takes under a second on a CPU.
- Content imitation. Grade B by fingerprint can be reached by editing unrelated content toward the registered
asset's coarse layout, or by placing the genuine asset unmodified inside a larger file. Only
confirmed_bytesand a trusted C2PA manifest resist forgery. - Key holders. Every process that can verify can also forge: a symmetric key has no verify-only form. Each detect replica holds the key; a replica breach is a key compromise. Laidline never holds your private payload keys. The shared pool key is the exception by design: it is public to every install (below).
- Choice of the checked payload. Whoever can edit a file can add a second mark, or force a tie, and so decide which
one payload detect checks, denying verification of a genuine mark. The response lists the payloads some read decoded
and the rule did not check (
other_payload_unchecked); it cannot list a mark that no read decoded, so an emptyother_payload_uncheckedis not evidence that a file carries a single mark. - Identity. A tag proves the payload was made under a key, not who holds the key or which organisation an issuer number belongs to.
- Removal, linkability and phantoms. A key does not make a mark harder to remove, does not hide the asset sequence (copies of one asset carry the same bits) and does not lower phantom rates against random inputs (per key it equals a check of the same width).
- Weak secrets. A secret that is not random can be searched offline against any genuine asset; Laidline accepts only 32 to 64 random bytes.
- Marks from unkeyed deployments. A keyed deployment reads their CRC-format image payload, shows the claimed issuer at grade D with a caveat (with presence, else nothing), and never counts them against its own evidence.
- Tag validity on the full tier. A credentialed caller learns whether a guessed tag is right for a registered value (C against D), K x 2^-t per call, within its budget; the public tier never shows it.
- The issuer's own word. Grades and statuses say what this server concluded, not that it is true.
- Denial of service. Each detect call costs the server a full decode and the confirmation views; a key does not reduce that, and only the per-address rate limit stands against it.
The shared family and the private key
Two key families mark. A mark says which one it was made under, and a verifier's wording follows (Licensing and metering: What a mark carries has the states that choose between them).
Shared (the pool key). One key, published to everyone, inside the signed directory (laidline.issuer-directory:
the snapshot shipped in the package, newer ones from laidline issuer refresh). Every install holds it, so a shared
mark proves "a Laidline mark" and identifies no provider: grade D, status confirmed_shared_key, shown as "shared
mark". Because everyone holds the key, anyone can forge a shared mark; it is public by design and carries no
attribution. Its details:
- It is injected into the key ring at runtime from the loaded directory (the active one marks, older ones
verify) and is never written in the configuration. - The deny-list check for published secrets skips it: the pool key is published to every install by design, so the check would otherwise refuse it.
- A rotated pool key reaches the key ring on SIGHUP,
POST /admin/reloador a restart. A directory refresh alone does not rebuild a running ring, so run a reload afterlaidline issuer refresh(or anissuer install). - A shared tag is accepted under any trial-range issuer number, not only the deployment's own, so a verifier recognises another install's shared mark.
- The shipped directory is the production snapshot carrying one pool key (label
pool-2026, key idpool-0b19a1bc728c92e5), verified under the pinned roots. If it cannot be verified, marking uses the configured key and the trial number, and logs a warning once (Licensing and metering: The Laidline roots).
Private (routable). The key laidline init writes (family: routable, state: active). It is inert until an
installed issuer certificate lists its key_fp for the medium; a key made by hand without registering changes
nothing. laidline register reads the fingerprint from the configured key and never rewrites the configuration.
After laidline payload-keys generate and a configuration change, run laidline register again, signed by the bound
signing key: the issuer replaces the binding per medium and no email step is needed. Both keys stay live for detection,
so earlier marks keep verifying; payload-keys generate prints a warning that the new key is
inert until it is re-bound. The family: private value of payload_keys.family is a reserved config value and is not
this private key (Configuration: watermark).
Where the issuer block appears. Every HTTP /detect response and the CLI laidline detect carry the issuer block
(what the number means, shared-mark wording, directory snapshot and caveat) on watermark signals, and on a valid C2PA
signal whose signing key the directory binds (Grading, issuer lines). The library call Marker.detect does not.
The meter. The family choice reads the month's credits from the evidence log through an in-process tally. A
long-running process starts it on the first keyed selection (the library) or at start (laidline serve), and refreshes
it hourly. A one-shot CLI run (laidline mark) refreshes the month once, synchronously, and only on a free plan: it
scans the month's entries at start, which on S3 costs requests. Replicas see each other's counts late, and each may
write its own payload_key_switch entry; the usage report uses the earliest.
Generate, configure, rotate
# mode 0600; prints the key_fp per medium
laidline payload-keys generate --out /run/secrets/laidline-payload-k2
generate writes 64 hexadecimal characters (32 random bytes) to a new file with mode 0600 and never replaces a file;
it prints only the key_fp of each medium (--id names the key id they are computed for). --stdout prints the
secret instead. To wrap a secret with AWS KMS instead (kms_ciphertext_file), encrypt its hex text under a symmetric
key with the encryption context Laidline pins at Decrypt:
aws kms encrypt --key-id <key> --plaintext fileb://secret.hex --encryption-context purpose=laidline-payload-key,key_id=<id> --query CiphertextBlob --output text | base64 -d > k.kms, and set
kms_key_id to the same key. Then add the key to watermark.payload_keys.keys (Configuration):
watermark: payload_keys: mode: keyed keys: - {id: k2, secret_file: /run/secrets/laidline-payload-k2, state: active} - {id: k1, secret_file: /run/secrets/laidline-payload-k1, state: verify}
laidline payload-keys listprints each key's id, state, media andkey_fpper medium (it resolves each secret and prints none);laidline payload-keys fingerprint <id>prints one key'skey_fpper medium.key_fpis the first 8 bytes of an HMAC of a fixed string under the derived key: it identifies a key in evidence and logs and reveals nothing about it.- Rotate: add the new key as
activeand set the old one toverify; it keeps verifying the marks made under it. Exactly one key per enabled medium isactive; at mostmax_live_keys(16) non-retired keys per medium. Every live key multiplies every forgery bound by K, so keep K at 1 or 2 in steady state. - Compromise: set
state: compromisedwithcompromised_at. A tag verified only under that key counts only for registry entries recorded before that time in the signed evidence chain. - Retire:
state: retiredstops a key verifying anything: marks made under it fall from B or C to D. Runlaidline payload-keys retire <id>first: it counts themarkedentries made under the key in a local evidence chain and refuses when there are any unless--orphans-marksis given (with another authoritative sink the count is unknown and the flag is always needed). It records aconfig_changedentryretire_acknowledgedwith the orphan count; it does not retire the key and does not edit your configuration: setstate: retiredthere and restart laidline (a reload re-reads the secrets only). The protection is advisory: the configuration can retire a key without the command. A Marker that starts with a retired key, or a key whose secret changed, that has marks under it logs a WARNING with their count (local evidence sink; the count is per key id). - Key events: every key added, activated, set to verify, marked compromised, retired, given a new secret or removed
writes one
config_changedentry with the key id, itskey_fpper medium, its state and media (never key material), followed by onepayload_keys_stateentry with the whole state. The Marker compares against the last recorded state on every evidence sink (a local scan, one S3 pointer objectindex/payload-keys/state.json, one Postgres query), so nothing is written when nothing changed. Switching a keyed deployment to unkeyed mode (or back) is recorded too. One-shot CLI commands (mark,detect,payload-keys retire) record no key events. A key change that cannot be written to the chain stops the start underon_mark_failure: fail_closedand is logged at ERROR underfail_open. POST /admin/reload(or SIGHUP) re-reads the secrets and swaps the payload keyring with the signing keyring; a failure keeps the old keyrings. Other changes to the section (states, keys, media) need a restart.LAIDLINE_ENV: the relaxed checks (an inline secret, a secret file readable by others, the published test secret) apply only whenLAIDLINE_ENVis explicitlydevelopment; unset is strict. The published test secret is refused in every other case.- Environment overrides reach this section like any other:
LAIDLINE_WATERMARK__PAYLOAD_KEYS__MODE=unkeyedturns keyed mode off andLAIDLINE_DETECT__UNAUTHENTICATED=fullserves the full result to everyone. Whoever controls the process environment controls both; both are visible in the evidence chain (the mode switch) or the configuration.
Storage
| Source | Notes |
|---|---|
secret_file |
A file with mode 0600 owned by the process user (or root); refused when group- or world-readable or writable, or owned by another user, unless LAIDLINE_ENV is explicitly development. A symlink (a mounted secret) is checked at the file it names. The laidline init default. |
kms_ciphertext_file |
An AWS KMS ciphertext of the hex secret, decrypted once at start-up with kms:Decrypt pinned to kms_key_id and the encryption context {purpose: laidline-payload-key, key_id: <id>}, so a blob made under another key or for another key id is refused (needs the kms extra; the key must be a symmetric ENCRYPT_DECRYPT key). |
secret_env |
An environment variable. The weakest option: readable through /proc/<pid>/environ, container inspection and crash dumps. |
secret (inline) |
Development only. |
The derived keys live only inside the keyring object; no repr, log line, evidence entry, response or admin status
shows a secret, a derived key or a tag; configuration dumps leave out an inline secret and the credential token hashes.
A secret on the deny-list of published example values is refused unless LAIDLINE_ENV
is explicitly development, and a secret that repeats one byte value is always refused.
TrustMark limits
TrustMark carries 61 bits, so its keyed layout has a 16-bit tag: below the 32 bits that NIST SP 800-107r1 asks of a
MAC tag. What protects it is the rules around it: the tag outcome is never shown on the public tier, the full tier shows
it only within a credential's budget, and the budget for 16-bit-tag failures is at most 100 per credential per 30
days (the smaller of tag_failure_budget_30d and 100). A medium whose scheme is a TrustMark carrier
(watermark.image.scheme: trustmark, watermark.video.scheme: trustmark_keyframes) accepts at most 2 live keys.
With these limits one valid guess per credential per month has probability 100 x K x 2^-16: 0.15 % at K = 1 and
0.31 % at K = 2. PixelSeal (28-bit tag) remains the default image scheme.
Response tiers (keyed mode)
Detect always builds the full result; Marker.detect and laidline detect return it. Keyed mode is decided from the
configuration. Detect credentials travel in their own header, X-Laidline-Detect-Token; /detect does not read
Authorization, so a gateway token (MarkClient's api_key, LAIDLINE_API_KEY) never collides with it.
MarkClient sends a detect credential only to /detect: MarkClient(..., detect_token=...) or
LAIDLINE_DETECT_TOKEN. Make tokens high-entropy, for example
python -c "import secrets; print(secrets.token_urlsafe(32))", and configure the SHA-256 of each
(detect.credentials[].token_sha256). The HTTP route serves:
| Caller | Response |
|---|---|
no X-Laidline-Detect-Token header |
detect.unauthenticated: public_tier (default), full (the full result) or refuse (401) |
X-Laidline-Detect-Token matching a detect.credentials entry |
the full result, or the public tier while the credential is over its tag-failure budget or the detect-state store is failing |
any other X-Laidline-Detect-Token value |
401 |
A keyed deployment whose payload keyring did not load (an unreadable secret under on_mark_failure: fail_open) serves
the public tier to every caller and logs one WARNING at start. The laidline_detects_total counter of /metrics is
labelled with the verdict of the body served, so it shows nothing the public tier hides. A keyed signal's issuer
block (a later stage) is shown on the public tier only at grade B; an unkeyed signal's is shown with claim: unverified
unless the public grade is B.
The public tier shows, per watermark signal, grade B or D only, with status confirmed_bytes,
confirmed_fingerprint, contains_registered_asset (with region: the box, the query size and the box's share of
the file) or present_unverified; at grade B only, the payload as read (issuer, asset sequence) and
other_payload_unchecked; the false-accept bound per live key; and caveats that do not depend on the tag. A present
signal below B shows no payload and names no issuer, whatever its tag outcome, and ends its caveats with "a mark is
present; its issuer could not be read". (On the full tier a keyed signal names an issuer only when its tag verified.) It never
shows grade C (issued_for_other_asset), the query fingerprint, fingerprint distances, unconfirmed asset ids, decoder
scores or anything about the tag or the registry. A full-result C becomes D (present_unverified). The top-level
verdict and grade are recomputed from the projected signals; evidence_log is kept only with a grade B or better.
Registry-only confirmation
detect.registry with detect.registry_confirms_without_tag: true (images, keyed mode) lets a checker confirm another
issuer's marks without that issuer's payload key: it reads the issuer's evidence store, read only, for heads whose
issuer is listed. For those heads no tag is computed (0 HMAC evaluations); the one padded lookup goes to that store; a
marked entry of the scheme and the decoded layout, whatever its key id, confirms when the file's bytes match or its
whole-image fingerprint is within detect.fingerprint_max_distance. Grade B then shows key_check: not_checked,
confirmation_basis: registry and the caveat confirmed against issuer N's records; key tag not checked by this checker on every tier; anything else is C on the full tier (D public). A region view never confirms on this path.
What B means here: the content matches the issuer's registered asset. Without the tag, anyone with the public weights
can write the issuer's number and a known sequence onto a near-duplicate of a registered asset and get B; an exact-bytes
match is unaffected. laidline.com's verify box uses this path for issuer 1 with a read-only grant and no key. Listing a
deployment's own number in detect.registry.issuer_ids makes its own marks registry-only too (no tag check, logged once
at start).
Unkeyed mode (no watermark.payload_keys, or mode: unkeyed): /detect answers every caller with the full result
exactly as before; detect.unauthenticated and detect.credentials are not applied.
Detect state, budgets and alerts
This applies to image, video and audio detect. A keyed audio stream's evidence entry is written when the stream closes,
so a detect of an excerpt of a stream still open finds no registry entry: the alert for a valid tag with no registry
entry skips the asset sequences of streams open on the same process (their records carry open_stream).
Every detect call with an eligible payload adds one fixed-size record to an in-process queue; a background writer
applies it to detect.state on its own poll (every half second), so no counter or alert is written on the request
path. With memory the counters are per process: they restart at zero, and each worker process has its own budget;
with sqlite the worker processes on one host share the file and re-read the credential counters every few seconds.
Budgets fail closed: a failure is counted in memory before any store write, a full queue's dropped failures still
count, and while the store fails every credential is served the public tier (the detect itself never fails on it).
The budget counters are kept for their 30-day window whatever retention_days says. The store keeps daily aggregates for retention_days: own-issuer tag
failures per medium, key id and issuer number, split by whether the named asset sequence is registered; unconfirmed
hits per registered asset with the number of distinct client buckets (a salted hash of the client address, the salt
rotated daily and held in memory); per-credential budget counters. Alerts are WARNING log lines that name counts and
ids. /admin/evidence/status shows a detect_state block with the counts, the budget use and the queue drops. Under
shared (trial) issuer numbers, other deployments' marks are a permanent background of unregistered tag failures: that
is why the registered and all-failure thresholds differ.
Equal work and its residuals
This applies to image, video and audio detect, on the file, Postgres and S3 registries. A keyed detect does the same work by construction for a checked payload whether its tag verifies or fails and whether the registry holds it:
- Tag. The tag is computed under every live key of the medium and compared with
hmac.compare_digest; the result is built the same way whether a key verified or not. - Lookup. One padded registry lookup returns a fixed number of slots, P = 4 (or the number of real entries when
there are more): the real entries first, then decoys. Every slot, a decoy's included, has the same byte width per
field (text padded to a fixed width, times in one canonical form); a value that padding or cutting would change
never counts, and a slot counts only for the issuer and asset sequence it names itself. The file registry parses every stored line once whatever matches and fills the slots by index
arithmetic. Postgres stores each marked row's slot (a 1,024-byte column) and P + 1 decoy rows in the same
table; one statement seeks the slot index at the payload's issuer and asset sequence and reads the first P + 1 rows
there: on a hit its own rows and the rows that follow them, on a miss the rows that follow the place it would sit
(a miss reads the rows that follow its place, not only the decoy rows every lookup reads). The decoy rows sort after every
issuer, so every seek finds P + 1 rows; the first P are parsed, and the last one only says whether more than P
entries share the asset sequence. A row counts only when its
eventcolumn ismarkedand its slot names the payload's own issuer and asset sequence, whatever else its stored slot says; a row at other columns (one that follows, or a genuine row copied there) never counts. S3 does the same seek: one LIST of the watermark index from the payload's place (StartAfter,MaxKeysP + 1) returns P + 1 keys on a hit and a miss (the watermark pointer keys are fixed-width,index/wm/<issuer:05d>/<asset_seq:015d>/<asset_id>.json, so every key of a product asset id has one length and byte order is numeric order; P + 1 decoy pointers end the index). The lookup then GETs the first P pointers (1,024 bytes each) and the entry each one names: one LIST and 2P GETs on a hit and a miss, of the objects at and after the payload's place (on a hit its own and the following ones, on a miss the following ones). A pointer counts only at the payload's own place, and a slot counts only through an entry at its chain key (entries/<day>/<stream>/<seq>.json, the key the pointer names compared with the entry's own) that is self-consistent (itsentry_hashis the hash of its content under itsprev_hash) and projects to exactly the slot; so an orphan, copied or following pointer never counts. A party with write access to the store can still plant one (an entry at a chain key and a pointer to it), andlaidline evidence verifyandexportthen show it among the entries, where it is no longer hidden outsideentries/. - Decision. Every slot is compared against every confirmation view; the counting, compromise and selection
predicates are evaluated in full and combined without early return or short circuit; text is compared at its fixed
width with
hmac.compare_digest, key ids as fixed-length digests. - Fingerprint records (keyed audio). Every keyed audio call with a chosen payload reads one
fingerprint record per slot, P reads of exactly one size (15,469 bytes), on a hit and a miss alike. Which record a
slot reads is chosen from the slot's own stored fields by index arithmetic, never from whether it counts: the record
its entry commits to when the slot names one (the slot carries the record's SHA-256), else one of P + 1 stored decoy
records (a pseudo-random fingerprint, every frame valid, which matches nothing). The file registry reads
fp/<sha[:2]>/<sha>.bin; Postgres runs one statement of P primary-key lookups intoevidence_fp; S3 makes P GETs offp/<sha>.bin(1 LIST and 3P GETs per call in all). Each record is bound to its slot byhmac.compare_digestof its SHA-256 with the slot's (a planted, swapped or corrupt record never confirms), then compared with the query's fingerprint by the fingerprint matcher on fixed shapes (media/audio_fp_fixed.py: the query padded to 20 s, the record at its fixed length, no branch on the data), on every slot; the result is ANDed with whether the slot counts. - Evidence. The evidence-log reference of a confirmed asset comes from the slot the decision confirmed; no second registry lookup is made. The reference, the region and the fingerprint block are built on every call and kept by index, so a keyed audio B (whose public body is a miss's) runs what a miss does.
- State. One detect-state record is queued per checked payload.
The decoys never reach a response, a counter or the evidence chain. Fingerprint decoy records: P + 1
records of 15,469 bytes; Postgres stores them in evidence_fp under the decoy stream at migration, S3 at
fp/<sha>.bin with the other decoy objects, the file sink at fp/<sha[:2]>/<sha>.bin when a keyed deployment
starts; a keyed start refuses a store without them, and a Postgres store holding keyed audio rows but no record
beside them (written before the records). A stored slot of the shape written before the record field (15 values)
never counts, and a keyed start refuses a store holding one: recreate it, there is no migration. Postgres: laidline evidence init-db (or
auto_init) adds the slot column, the slot index, an index of marked rows without a slot, and the P + 1 decoy rows
(stream s-decoy-0000000000000000, event decoy, issuer 2,147,483,647, after every issuer), which no reader of
entries returns. A schema whose decoys sit under issuer -1 (an earlier layout) is refused at start: recreate it.
S3: a keyed deployment's sink writes P + 1 decoy entries (index/decoy/entry-<k>.json, outside entries/) and
the P + 1 decoy pointers naming them (index/wm/99999/<k:015d>/decoy<k:025d>.json, after every issuer) before its
first entry of any kind (a keyed deployment writes them at start, before its store check), and puts each watermark
pointer after its entry. Refused at start: a store with any watermark pointer at the unpadded keys of an earlier layout,
mixed with current ones or not (one delimited listing of the issuer directories and two one-key listings per issuer;
an unpadded key of a 5-digit issuer that sorts after a padded asset sequence of 10^14 or more, which only 48-bit
unkeyed video produces, needs laidline evidence check-slots to find); a store with pointers but without its ten
decoy objects. The seek needs a general purpose bucket: a directory bucket (--x-s3) is refused at start, and a
listing page out of byte order, or short while it says it is truncated, raises an error instead of being read. The slots are not
part of the hash chain: laidline evidence check-slots checks every stored slot and decoy against the chain. A store
holding marked rows or watermark pointers without a slot (written before the padded layout) is refused when a keyed
deployment starts.
How it is tested. Structurally, not by wall clock (the equal-work tests in the Laidline source): for a valid and a failed tag against no entry, one entry and two entries, a
valid tag under a compromised key, and an ambiguous choice, the tests assert equal bytes parsed, equal calls per
backend (S3 LISTs, the keys and key bytes each returns, GETs, pointer bytes, distinct objects and no GET of a
missing object; Postgres statements, rows returned, and the SQLite virtual-machine instructions the statement
executes), the same field widths for every slot,
equal HMAC evaluations, comparisons and request-path writes, absolute compare_digest counts, and an equal number of
Python instructions executed in the product's detect modules (sys.monitoring), and, on the file and Postgres
backends, of every Python instruction executed outside the tests on the calling thread, generated dataclass methods
and the standard library included (other threads, such as a test runner's, are not the lookup's;
S3's waits on its read pool make the count depend on thread scheduling, so it is not compared there), on real-size entries.
A further test counts the keyed video call on the file registry: one value for every probe of
the four sets in both benchmark draws (lines and bytes read, json.loads calls and bytes, instructions). The Postgres
statement runs for real on SQLite with the product's indexes (a hit and a miss read the
same number of rows of one size, no decoy row inside the key space, with the same instructions; one index search, no
sort),
and on Postgres 16 in a live test (an index-only scan of the seek index
returning P + 1 rows, with the same heap fetches and shared buffers on a hit and a miss). The S3 seek is pinned
against a recording stub in the same test (one LIST of P + 1 keys of one total length, P pointer GETs of 1,024
bytes, P entry GETs, no decoy object inside the key space, the same objects read on a hit and a miss at the same
place) and on AWS in a live test. The fingerprint record read is
counted in the same harness on rows {tag valid, failed} x {no entry, one entry, two entries} x {the fingerprint
confirms, no match, low content}, with real records: the reads (file reads and bytes; the Postgres statement, rows,
record bytes and SQLite instructions; S3 GETs and bytes), the SHA-256 calls and bytes, compare_digest counts and
the instructions of the matcher are equal on every row; the matcher alone executes one instruction count for a
record valid for 10 s, a full one and a decoy against any query. That count is a
property of the matcher, which has no branch on the data (a decoy whose mask were all zeros would give it too); what
carries the matcher's correctness is its equality with the matcher's search and search_naive on the matcher's fixtures and
on random pairs, and the decoys' all-valid mask matters for the work below the interpreter (residual 1). On Postgres 16 the
record statement has one plan on a hit and a miss and every record is stored out of line at one size
(a live test); on S3 it is P GETs of one size (a live test).
What the tests cannot show (residuals):
- Below the interpreter. Instruction counts and bytes are equal; their cost is not exactly (memory compares stop
at the first differing byte, CPython's allocator and caches). The meters count bytecode and, by calls and bytes,
json.loads,hmac.compare_digest, the HMAC,hammingand the chain hashentry_hash(S3's slot check); C-level work outside that list is not counted:sorted,str.rstrip,datetime.fromisoformat, the SHA-256 in the arbiter's_digestand in the chain-key comparison, the canonical JSON dump insideentry_hash(its bytes are counted, its work is not),int()andstr(). The S3 lookup sorts its P slots by (own place, stream, seq): on a hit the comparisons are decided at the first element (own against following), on a miss they compare streams, so the C-level work of the sort differs. These are nanosecond effects. Keyed audio adds the matcher's numpy work, the SHA-256 of each record andcompare_digeston equal lengths, whose cost moves by nanoseconds with content (for example a minimum over infinities against finite values). - The size of each slot's entry (S3). The lookup GETs, parses and hashes each slot's entry; a real entry's size and shape are its own (about 1.2 to 1.6 kB), the decoy entry's are those of a marked image entry. A hit reads its own entry and those of the following places, a miss those of the following places, so these sizes depend on where the asset sequence sits more than on whether it is registered (see also 9). The hash is computed outside the instrumented modules.
- The S3 LIST response. It always carries P + 1 fixed-width keys. What the keys and their
listing metadata (ETag, last-modified time) say is the neighbours', the same kind on a hit and a miss. When fewer
than P + 1 pointers of the payload's issuer lie at or after its place, the keys that follow are the next issuer's
pointers or the decoy pointers. A probe past the highest registered (issuer, asset sequence) of the whole index
reads only the decoy pointers, and its listing comes back complete (
IsTruncatedfalse): no hit can sit there, so timing can tell that a probe lies in that region, which reveals roughly where the top issuer's highest registered asset sequence lies and so, since asset sequences are hashes, roughly how many marks that issuer has. The same holds on Postgres (4). S3's own service time is not measured, nor that of the P record GETs of keyed audio (their count and bytes are equal on a hit and a miss; a live test pins them). - Postgres server work. A hit and a miss read the same kind of rows from the same place in the index (a hit its own rows and the rows that follow, a miss the rows that follow its place), so these residuals (a count of the real rows; decoy rows read on every miss and so likelier cached; a real slot and a decoy slot compressing differently) no longer separate a hit from a miss as such: they depend on where the asset sequence sits, which the payload fixes whether or not it is registered. A narrow timing side channel remains (9). What else remains: when fewer than P + 1 marked rows of the payload's issuer lie at or after its place, the rows that follow belong to the next issuer or are the decoys, and the seek's row comparison ends one column earlier for them (SQLite: 3 fewer virtual-machine instructions per such row), so there a hit's own row takes the longer comparison and a miss's does not. A probe past the highest registered (issuer, asset sequence) reads only the decoy rows, where no hit can be (as on S3, 3: the top issuer's highest registered asset sequence can be located by timing). A B-tree also places a miss between two cells, so a miss that falls at the end of a leaf steps to the next leaf (about 0.04 page-cache touches per lookup on the bench). Both are nanosecond effects. The statement runs on Postgres 16 in a live test; buffer and heap behaviour at production size is not measured. The keyed audio record statement makes P primary-key lookups and reads P records out of line (TOAST) of one size on every call; which pages are cached follows 10.
- More than P entries. A lookup that finds more than four entries (assets sharing an asset sequence) makes one more Postgres statement or S3 LIST to read them all and compares that many slots, so the count above P is visible by timing; keyed audio reads and compares that many fingerprint records too.
- A store with no decoys, or a missing entry. An S3 store no keyed process has written to, or a Postgres schema
whose decoy rows are missing (refused at start), reads the built-in decoy instead (logged); S3's GET of a missing
object fails fast. A watermark pointer whose S3 entry was deleted, or whose entry does not parse, takes the decoy's
path and never counts; it parses the stored decoy object's bytes in place of the missing one, so
its parses equal a miss's, and what remains is one failed GET in place of a read (asserted in the equal-work tests). A store missing an entry its pointer names has been edited outside Laidline. A
keyed audio record that is missing, of the wrong size or under the wrong name is read as a decoy record's bytes and
never confirms; the failed read takes another path than a healthy one and is logged as a WARNING, and
laidline evidence check-slotslists it. In a healthy store every record a slot names exists (it is written before its entry). - The full result. A credentialed caller's full result lists the n unconfirmed asset ids of a C; the public tier drops them before serialising. Building that result allocates a tuple of the n ids on a C and none on other outcomes (an empty tuple is shared): the one net tracked container that differs on the file registry (the count: it counts containers left after the call, not untracked objects such as strings, nor containers freed within the call).
- The file registry's size. The file evidence sink's registry parses every stored line on each lookup, so its lookup time grows with the number of entries and the marking volume is readable by timing. The file sink is for development and small deployments; use S3 or Postgres in production.
- A timing side channel in the registry seek (Postgres and S3). The padded lookup's seek has a timing side channel
that can reveal, to a caller who already holds a payload naming a specific (issuer, asset sequence), whether that
payload is registered. What bounds it: HTTP detect calls are limited per client address (
detect.rate_limit, default60/min/ip: an IPv4 address, or an IPv6 /64, per server process); every such probe carries a failed tag (a caller without the key cannot make a valid one), which the daily detect-state aggregates count, split by whether the named asset sequence is registered, and which raise alerts, so probing is visible to the operator; a caller gets one observation per cache eviction; and each probe is a full image or video decode, against a difference of about one page read. The response tier and the credential budget do not bound it: the detect, the lookup included, runs in full on every tier. It is the same bit a credentialed caller reads on the full tier within its budget (registry: hiton a failed tag), so it lowers the cost of a bit the full tier already gives credentialed callers rather than exposing a new one. Accepted as a stated residual. - The fingerprint record read (keyed audio). The record read of a keyed audio call has a timing side channel of the same class as 9: it can reveal, to a caller who already holds a payload naming a specific (issuer, asset sequence) and that asset's audio, whether the payload is registered. What bounds it: the per-address rate limit; a failed tag on every such probe, counted and alerted; one observation per cache eviction; and a full audio decode and four fingerprint comparisons (about a second of CPU) per probe against a difference of one 15 kB read. It is the same bit a credentialed caller reads on the full tier. The option that would remove it was not taken.
- The query (keyed audio). The query fingerprint is computed from the caller's own audio, so its cost follows the query's length and content, which the caller already holds; it is computed on every call with a chosen payload and on no other call.
- Replication (keyed audio). S3 replication copies objects one by one, so a replica bucket can hold an entry before its record; a detector reading a replica then reads that slot's record as a decoy's (presence or C, not B) until the record arrives. Postgres replication applies the entry and its record in one transaction.
Timing channels: bounds and mitigations
This section states that the channels exist, what they can reveal, what bounds them and what mitigates them. It does not describe how to exercise them.
The mitigation in general. Keyed detect does equal work by construction (above), proven by counting tests and live runs on Postgres 16 and S3. Wall-clock re-times are a sanity check under a multiplicity-aware rule: a cell blocks only when it is one-signed over its A/A spread in all runs and reproduces in an independent re-time. On that basis the keyed video product draw passes and the keyed image cells are restated with no cell one-signed; the keyed audio file-registry timing cell and the locker's watch cell are reported under "Audio" below.
Residual channels accepted as stated.
| channel | what it can reveal | bounds and mitigations | status |
|---|---|---|---|
| The registry lookup (Postgres and S3) | to a caller who already holds a payload naming a specific issuer and asset sequence, whether that payload is registered | per-address rate limit (detect.rate_limit, default 60/min/ip); every such probe carries a failed tag, which the detect-state aggregates count and alert on, so probing is visible to the operator; one observation per cache eviction; each probe costs a full decode against a far smaller difference; it is the same bit a credentialed caller already reads on the full tier within its budget |
accepted |
| The keyed audio fingerprint record read | the same bit, to a caller who also holds that asset's audio | the same rate limit, failed-tag counting and alerting and eviction bound; about a second of CPU per probe against a much smaller timing difference | the option that would remove it was not taken |
Coarser effects of store extent and size, and work below the interpreter of nanosecond scale, are listed as residuals 1 to 8 and 11 to 12 above. The file registry is for development and small deployments; use Postgres or S3 in production.
Images
Layouts. Every keyed layout carries a 16-bit issuer field. The unkeyed payloads are wider (22 bits for an image, 24
for VideoSeal; Evidence log); the issuer number is 1 to 65,535 in both. Keyed image payloads
keep the carriers' widths, so channel codes and decoders are unchanged:
pixelseal-i77-r1 (77 bits: version value 2 in 3 bits, issuer 16, asset sequence 30, tag 28) through PixelSeal's
BCH(127, 78) code without the CRC-16, and trustmark-i61-r1 (61 bits: version value 2 in 3 bits, issuer 16, asset
sequence 26, tag 16) through TrustMark's own BCH. The marked entry's watermark block records payload_layout,
payload_layout_id, key_id, key_fp, tag_bits, asset_seq (at the layout's width) and payload_hex (the full
layout bits, tag included; Evidence log). The C2PA soft binding carries the same bits under
com.laidline.pixelseal.i77-r1 or com.laidline.trustmark.i61-r1. In keyed mode a mark is never written unkeyed:
without a loaded keyring the image is not marked (payload_keys_unavailable, on_mark_failure applies).
One check per call. Decoders never call the key. Each read (PixelSeal) or variant (TrustMark) gives its top
candidate; a top is eligible when its head parses under the layout with one of this deployment's issuer numbers.
The scheme's own rule picks at most one
payload: PixelSeal's direct read is final
when its top is eligible, otherwise exactly one distinct eligible payload over the retries (two or more: none checked,
listed in other_payload_unchecked); TrustMark the first eligible variant. Only that payload is tag-checked, under
every live key. The read sequence is the unkeyed one with "eligible top" in place of "accepted payload": deep scan runs only
when no default read has an eligible top. TrustMark's chain stops at the first eligible variant, so the later variants
are never decoded and a TrustMark signal's other_payload_unchecked is always empty: it lists only payloads some read
decoded and the rule did not check.
Presence. A PixelSeal call shows a mark as present (grade D, present_unverified, with the payload's issuer as a
claim) only when some default read's top candidate scores above 6.403 (pixelseal.PRESENCE_MIN_SCORE, fitted and
validated in a follow-up: 0 of 2,500 held-out unmarked photos and 0 of 200 unmarked page captures
above it, 300 of 300 keyed identity embeds above it). Deep reads never give presence. It is weaker on some copies of a
genuine mark: about 75 % of screenshots, 73 % of WebP q50 copies and 58 % of half crops are above it, so a genuine but
unverified mark on such a copy shows nothing publicly. TrustMark has presence only under decode: hardened (its
calibrated gates); under decode: fast a keyed TrustMark image shows B or nothing publicly.
The 2026-10 null campaign checked the keyed image read on unmarked photos: no read presented any of 38,500 unmarked photos or
2,200 screenshots as marked (presence 0, tag accepts 0; Wilson 95 % upper bound 0.0100 % and 0.174 %;
Benchmarks).
Decode rates. On the six rows measured (TrustMark WebP q50 and JPEG q50 under the fast decoder and the hardened decode, PixelSeal WebP q50
and 50 % crop), a keyed payload costs at most about one point of decode rate against the unkeyed CRC layout. Rows not
measured are not claimed. The hardened decode is the default; decode: fast is a measured opt-out.
Timing check (images; reported, indicative). The keyed image call's hit-against-miss timing channel stays within the bound stated in residual 9 on every backend measured. The mitigations are the equal-work construction ("Equal work and its residuals", pinned structurally, not by wall clock) and the registry recommendation: the file registry is for development (residual 8); production uses Postgres or S3.
Still frames of keyframe-marked videos. A frame of a video marked with the keyframe scheme carries
trustmark-kf61-r1 under the video key. Image detect configured for TrustMark reads the same 61 bits as
trustmark-i61-r1 under the image key, so its tag fails: such a frame grades D (with presence) or nothing, where
unkeyed mode grades it C. The full tier says "payload parses as a keyframe layout".
Insets. When the registered image confirms only on a cut or a box of the query (an unmodified marked image inside
a larger page), the status is contains_registered_asset with region (box, query_size, area_fraction) and the
caveat "the registered asset occupies about N % of this file; nothing else in the file is covered by this result",
whatever read decoded the payload.
Two own issuers. Detect treats every number of own_issuers (by default watermark.issuer_id) as its own; the
registry lookup and the tag message use the number the payload carries.
Video
Layouts. videoseal-v92-r1 (92 bits: version value 2 in 4 bits, issuer 16, asset sequence 40, tag 32) goes through
VideoSeal's BCH(255, t=16) code as the unkeyed payload does (the CRC-16 is replaced by the tag); trustmark-kf61-r1 (61
bits: version value 2 in 3 bits, issuer 16, asset sequence 26, tag 16) is carried by the keyframe scheme. A payload of
the layout trustmark-kf61-r1 is carried by TrustMark variant Q on one forced IDR frame per keyframe_interval_s
(the same positions as the unkeyed payload); nothing else carries it and no reader of it tries another variant.
The marked entry's watermark block records the layout fields as for images, plus, for keyframes, variant (Q, the
variant used), keyframes_skipped and skipped_positions; the C2PA soft binding is com.laidline.videoseal.v92-r1 or
com.laidline.trustmark.kf61-r1. Without a loaded keyring a keyed deployment does not mark video
(payload_keys_unavailable).
The floor. A position whose Q embed is under watermark.image.min_psnr_db (38 dB by default) is left unmarked,
still encoded as an IDR frame, and listed in skipped_positions; when every position is skipped the video carries no
mark (watermark_skipped:keyframes). On the 20 derf clips of the keyed video measurement below, 1 clip
(mobile_1) had every position under the floor for Q and also for the unkeyed chain (Q, then B and C), and no other
position was skipped; this says nothing about a customer's content.
The keyframe reader. Keyed keyframe assets are read by the hardened Q-only reader whatever
watermark.video.decode says: the key frames and marking positions vote through TrustMark Q's calibrated gates (a
frame's word is its gate-passing top, whatever its payload version), exactly one distinct eligible payload is chosen,
two or more are ambiguous (final, nothing checked, all listed in other_payload_unchecked); when the vote finds no
eligible payload the scan reads every frame within 0.5 s of a position with the same reader. The pooled-consistency
rule (Grading) applies to the chosen payload over the deciding step's reads: a chosen payload the frames'
gate-failing reads outscore is not chosen; the result is ambiguous, nothing is checked, nothing is listed in
other_payload_unchecked, and the full tier's reason is "the frames' reads disagree; no payload was checked". The
claimed payload shown with presence is the highest-scoring gate-passing top of the deciding step (the scan's when it
ran), and only when it stands under the same rule over the same reads; otherwise presence is shown without a claim. The ungated fast keyframe vote is
not consulted for keyed assets; under decode: fast the Marker logs at start-up watermark.video.decode: fast does not govern keyed keyframe detection: keyed keyframe assets are read by the hardened Q-only reader (vote, then scan), and
the full tier says reader: hardened_q. Presence (grade D) is a gate-passing Q read.
VideoSeal. The words are in the unkeyed order with no CRC test: the fast-decoder word (BCH and the padding rule on the mean
logits' signs); under decode: hardened, when it is not eligible, the soft inputs (mean, l1norm vote, 4 windows),
each input's top Chase candidate; with the geometry search, when no word is eligible, each hypothesis's words. The
first eligible word is checked; every other eligible word is listed in other_payload_unchecked. Presence (grade D)
is any BCH-valid word.
Grade B by fingerprint. A video is confirmed by its exact bytes (confirmed_bytes, scope whole) or by the
fingerprint of the frame at 1 s (confirmed_fingerprint, scope region, region: {frame_s}) with the caveat "one
frame of this video (at T s) looks like the same frame of registered asset X at thumbnail scale; the other frames
were not compared; this rules out coincidence, not imitation".
Unkeyed-format video payloads. No unkeyed-format video payload is read under keyed mode: a CRC head is never eligible and gets no tag check, no registry lookup and no state record.
other_payload_unchecked. Non-empty under ambiguity for keyframes, and for VideoSeal when later words were also
eligible; always empty for TrustMark images (its chain stops at the first eligible variant).
Measured.
- Unmarked clips. In the 2026-10 null campaign (rules sealed before any clip was read), 4,854 unmarked 5 s clips
(user-uploaded video 3,041, Wikimedia Commons 1,618, Xiph 93, the project's own 1080p clips 46, open-licence film
footage 56) went through three reads with the shipped defaults: keyed VideoSeal, the keyed keyframe reader and unkeyed
VideoSeal. Tag accepts 0 of 4,854 for keyed VideoSeal and 0 of 4,854 for the keyframe reader, presence 0 of 4,854 for
unkeyed VideoSeal, no payload word, tag check or score on any read; Wilson 95 % upper bound 0.079 % at each read
(Benchmarks). 40 further clips of 4096 x 2160 were refused by
watermark.video.max_pixelsand are not counted (0.71 % of the set). An earlier, smaller run is kept as a dated record: 1,740 unmarked YouTube UGC clips through keyed detect gave 0 graded B or C, 0 tag checks and 0 presence events for the keyframe reader (246,372 frame reads, none passing the gates; the best score 1.70 against the gate 2.95) and for VideoSeal underdecode: hardenedwith the geometry search (0 BCH-valid words); Wilson upper bound at 0 of 1,740: 0.22 %. - Saved rows. On 2,514 saved keyframe rows read with Q alone, the keyed rule lost 0 of the 759 rows the unkeyed rule decodes on the same reads, with 0 wrong eligible frames of 2,114. Reading Q alone loses rows whose saved mark was read through B or C: 3 such rows in this set (a further 2 were classed as not reproduced and as a regeneration or device difference). On 516 geometry rows (43 clips, GPU) the keyed words, the geometry hypotheses included, lost 0 of 486 rows the unkeyed search decodes.
- Detect cost (indicative; 2 threads on reserved cores at load 0.3 to 2): an unmarked 5 s clip takes 9.5 s at CIF and
18.5 s at 720p50 through the keyed keyframe reader (vote, then the every-frame Q scan), against 23.3 s and 41.9 s
through the unkeyed
decode: hardenedchain; a marked clip is read in under 1 s (the vote). Keyed VideoSeal on the CPU (geometry search off): 5.6 s and 11.1 s. The 3 s target is not met by the keyframe reader on unmarked clips; the levers that could change it (INT8, all cores) are not measured here. - Decode rates. None is claimed beyond these counts: on identity, 19 of 19 keyed keyframe embeds of the derf sample (the 20th clip carried no mark) and 58 of 60 independently embedded keyed clips (the same 2 clips also failed in the unkeyed CRC format) decoded and verified; 40 of 40 keyed VideoSeal embeds decoded and verified.
- Timing check. The keyed video call's hit-against-miss timing channel stays within the bound stated in residual 9 on every backend measured. The mitigations are the equal-work construction ("Equal work and its residuals", pinned structurally, not by wall clock) and the registry recommendation: the file registry is for development (residual 8); production uses Postgres or S3.
- Live checks. The KMS-wrapped payload secret (
kms_ciphertext_file) ran against real AWS KMS, on a temporary symmetric (SYMMETRIC_DEFAULT) key in eu-central-1. Not established: the Postgres statement has run on Postgres 16 locally, not at production size.
Audio
Layout. In keyed mode Laidline writes audioseal-p70-r1 (layout id 0x31) into audio: 70 bits holding a 2-bit
version, the 16-bit issuer number, a 28-bit asset sequence and a 24-bit key tag. A BCH code (m = 7, t = 9) protects
them. They are spread over 1.0 s cells of an 8 s cycle (cell layout audioseal-p2-c8x16-s10-v1) that starts at a
random cell per asset (c0). The mark is written at alpha 1.41, which Laidline sets for keyed audio
(watermark.audio.alpha may be left out or set to 1.41; any other value is refused at start). The payload needs about
10 s of audio from an intact start (keep 10 s as the claim); it reads from 8.0 s of delivered audio on a 320-sample (20 ms)
boundary (a stream fed 10 s delivers 9.5 s, because 0.5 s is held back at close), and a start off that boundary is found by
the phase search (below), which was added after the 2026-10-27 voice-call matrix measured the off-grid loss and is off by default (payload.phase_search; measured in the 2026-10-30 v2 matrix).
Presence reads from 1.5 s. Shorter audio is marked all the same; its payload does not read (payload_read: too_short), and
it grades D at most. Measured codec, noise and reverb rows: Audio.
Raw AAC (aac, ADTS) cannot signal the encoder's 1,024-frame priming. Laidline therefore puts pad_samples frames of
silence before keyed aac output (256 at 16 kHz, 740 at 44.1 kHz, 896 at 48 kHz), so that the decoded mark stays on
its 20 ms grid. A streamed MP3 has no header to carry its 1,105-frame priming, so a keyed MP3 stream from the speech
proxy gets the same kind of pad (175 frames at 16 kHz, 335 at 24 kHz, 659 at 44.1 kHz, 815 at 48 kHz). File MP3, M4A,
Opus, FLAC and WAV need no pad. The entry records the pad.
Streams. The streaming speech proxy, laidline-livekit, laidline-pipecat and Marker.audio_stream mark keyed
streams with the buffered embedder (arm L2000-R200-H500, recorded as stream_arm). The first marked frame arrives
after 0.70 s of audio (algorithmic latency: a look-ahead of 10 blocks plus a hop of 25 blocks of 20 ms), and the last 0.5 s
is held back. The session releases it, marked, when the stream ends (flush; close returns it as tail), and the
integrations relay it before the end of the utterance. A stream that ends early (a barge-in, a cancelled turn, a client
that disconnects) never sends those frames, so a barge-in delivers the fed length minus 0.5 s. Its entry, its metered seconds and its
fingerprint record then cover only the audio that left.
Reading. Detect reads in two stages and checks one tag per call:
- Stage 1, every keyed audio detect, on CPU: the default read of the intact-start envelope, isolated reads on three
20 ms grids (
payload.reader: three_grid, the default;five_gridreads five). - Stage 2, deep scanning: the hardened read for trimmed, offset or excerpted audio. It runs only when stage 1
chose nothing, the hardened detection fired, and
payload.hardened_readallows it. The default isoff;autoruns it on a CUDA device (on CPU,hardened_cpu: rundecides);onalways runs it. - The span read: under the hardened decode, when stage 1 did not detect, the payload of a mark inside a
longer recording, read on its best 12 s window at the phase search's coarse peaks and then at the searched phase,
stopping at the first choice (
payload.span_trigger, on exactly when the decode is hardened). Its words join stage 1's; the call still checks one tag. - The phase search (
payload.phase_search,offby default; the rest of this item is whatondoes): under the hardened decode, when the batch card's hardened verdict is a detection and nothing is chosen yet (a delayed or trimmed clip, a codec's lag: the mark is off the 320-sample grid), the keyless PH2 scan of the 320 phases on the batch card over the read span, then isolated reads at the two best picks minus phase 0 and the span read's phases (so 0 to 2 phases), stopping at the first choice. Its words (at most 48, or 80 with five grids) join stage 1's; the call still checks one tag. It is keyless: it never touches a tag or the registry, and it adds nothing to a record unless it ran (extra.payload_read.phase_search: the phases read, its words, the segments scored, the status).
Grades. A P70 payload grades B only when the content confirms: the file's bytes or the signed twin's bytes
(confirmed_bytes, scope whole), or the registered asset's fingerprint record: confirmed_fingerprint
when the matching windows cover the whole searched query and the query is the whole file, contains_registered_asset
with region: {start_s, end_s} when part of it matched. It grades C (issued_for_other_asset, full tier only) when
it is registered and not confirmed, and D (present_unverified) otherwise. It never grades B on the payload alone. A
copied mark verifies on any audio it is pasted onto, so grade B needs the content to match: the bytes, or the audio
itself by fingerprint, which a payload copied onto other audio does not pass.
What each tier shows.
- Full tier: the payload, tag, registry and key fields as for images and video. It also shows
extra.stage(defaultorhardened),extra.payload_read(chosen,tie,too_short,not_attemptedwith a reason, ornone), and the false-accept bound per detected clip. The bound is W x 1.009e-2 x 2^-2 x n_own x 2^-16 x K x 2^-24, taken at a call that runs every read, the phase search's words included: W = 392 with the three-grid reader (440 with five). At K = 1 and one own issuer that is 9.0e-13 (1.0e-12 with five grids). With the span read on, a call can also read its 72 words: W = 464 (512), 1.07e-12 (1.17e-12). - Public tier: presence only,
{type, scheme, present: true, grade: "D", status: "present_unverified"}. The scanner shows no payload, issuer, asset sequence, tag, key, registry, bound, region or score, and no grade B. A watermark of another deployment is reported as present, with the caveat "a watermark of another deployment is present; it is not a mark of this Laidline deployment". Grade B for audio is shown only on the full tier. The rule is a head, a version-2 payload of a foreign issuer, that tops words scoring at least 0.97 on three or more grids (the score is 1 minus the share of weighted bits the decoder flipped to reach the payload, so 0.97 means it flipped at most about 3 %). The three grids and the score floor were chosen after the data of the powered measurement below, and the floor was tested once on a fresh 3,000. An earlier two-grid rule counted one wrong word twice on three of Laidline's own marks. Those were grids 200 ms apart, whose 1.0 s segments overlap by 80 %, or a stage-1 and a stage-2 read of the same word. Three grids cleared all three and kept all 40 measured foreign marks; their independence is measured, not shown. The own-mark line (at most 1 label per 1,000 own-mark calls with no own payload) had no power at the 31 calls measured. The powered measurement read 3,000 of Laidline's own marks tagged under foreign keys, every word of both stages kept, and scored each read as if no own payload had been chosen. That is the worst case: the own payload was in fact chosen on all 3,000, so the product showed no label on these calls. Under it the three-grid rule labelled 13 of 3,000 (4.3 per 1,000; exact 95 % interval 2.3 to 7.4). The line stated before the data (7 or more labels fail) failed: whenever no own payload is chosen, the rule labels an own mark as another deployment's more often than 1 in 1,000. The two-grid rule labelled 80 and R6' (two grids and two word positions) 21. Each of the 13 was one foreign word decoded on three or more grids by one detector, in 6 cases from one word position. No line moved. The failure is recorded here. The score floor. Every word of the 13 scored at most 0.9445, while every genuine head (the 3,000 own payloads scored as if foreign, and the 40 foreign marks measured earlier) scored at least 0.9788 on its third grid. The floor 0.97 was chosen after that data, so it was tested once on a fresh 3,000 own marks, with the same construction ("scored as if no own payload were chosen") and lines stated before the data (recorded in the benchmark harness before the run). Under the floor rule the hold-out labelled 0 of 3,000, which shows the rate below 1 per 1,000 (one-sided 95 % upper bound 1.0 per 1,000); the three-grid rule without the floor labelled 14 on the same reads. Labels kept: 2,999 of 3,000 own heads scored as if foreign (at least 99.84 %, one-sided 95 %; the one missed head's third grid scored 0.9684) and 150 of 150 fresh clean foreign marks labelled by the product (at least 98.0 %). Under Opus 12 kbit/s, reported as measured and not promised: the product labelled 129 of 150 foreign marks (86.0 %; Wilson 95 % 79.5 to 90.7 %) and showed presence on all 150; on 3,000 own marks through Opus 12 the own payload was chosen on 2,982, and none of the 18 calls without it was labelled. When a label is missed, the call says less, not something false: if the 16-bit detection fired, the full tier shows presence with the caveat "a mark is present; its issuer could not be read", and the public tier shows presence only when that verdict came on a phase-0 path (on a fallback path the public tier shows the absent body); if it did not fire, a head under the floor shows nothing (no presence claim from a sub-floor head). The rates are for the measured construction (P70 marks at alpha 1.41 on 13 to 17 s train-clean-100 speech), read on the GPU in full float32 (TF32 off for matmuls and cuDNN), which is the shipped CUDA precision (watermark.cuda_precision: strict, the default). Under torch's default TF32 decisions near a threshold can differ.
The 16-bit AudioSeal mark names an issuer and nothing else: it has no tag and no asset. It never grades above D in keyed mode. What a 16-bit read shows depends on the deployment:
- Keyed mode with the P70 payload (the default): a 16-bit read is never verified and never shows an issuer. It shows presence only, with the caveat "a mark is present; its issuer could not be read".
- Keyed mode with
payload.layout: none(allowed, with a WARNING at start): the 16-bit mark keeps the base keyed behaviour on the full tier. The claimed issuer is shown there, D (present_unverified), with the caveats "The 16-bit AudioSeal mark names an issuer only; it carries no key tag and no asset, so this deployment cannot verify it" and the flipped-bit sentence (a 16-bit mark has no error detection: in the 2026-10 null campaign 40 % of marked music clips named another issuer at full confidence). The public tier shows presence only, D, with the caveat "a mark is present; its issuer could not be read": no payload and no issuer, since an unverified 16-bit word is a guess. - Unkeyed mode: an unkeyed deployment serves the full result to every caller, and for audio that result is
presence only (decided 2026-10-28): grade D,
payload: null, noissuer_match, noissuerblock, and the caveat "an unkeyed audio mark carries a 16-bit word with no error detection; this deployment reports presence only and names no issuer". The 16-bit word has no error detection (one flipped bit renamed the issuer on 40 % of marked music clips), so no caller is shown it. Unkeyed audio never grades C. Unkeyed image and video words keep their CRC-checked layouts and the unkeyed-format caveat. - Keyed mode with the P70 payload under
decode: hardened(the defaultautochooseshardenedon a CUDA audio detector): the presence decode adds a cell-local statistic beside the fine-read floor and a located path. Their presences are fallback paths, shown on the full tier with a caveat and never on the public tier unless confirmed. The statistic is computed on every fallback read of the batch card, whether the clip is marked or not, and it reads no key and no payload (Grading, "The cell gate for keyed P70 audio").
Equal work. Keyed audio detect uses the forms above ("Equal work and its residuals"): a tag under every live audio key, one padded lookup, every slot compared, no second lookup, and one state record per checked payload. Whether stage 2 runs depends only on stage 1's keyless choice and the hardened detection, never on a tag. The audio rows of the equal-work tests cover a valid and a failed tag; no entry, one entry, and two entries sharing an asset sequence; stage 1 and stage 2 choosing; and an ambiguous call. They run on the three registries with real-size audio entries. The fingerprint rows add the record read and the comparison: the fingerprint confirming (a B, whose public body is a miss's), not matching and a query of low content run the same operations. What varies, and is not secret: decode time with content, whether stage 2 ran, and the residuals listed above.
Keyed audio timing (report only). The measured hit-against-miss timing channel of the keyed audio call, record read included, stays within the bound stated in residual 10 on every backend measured; one file-registry cell is unexplained and indicative. The mitigations are the equal-work construction (above, pinned structurally, not by wall clock) and the registry recommendation: the file registry is for development (residual 8); production uses Postgres or S3.
The fingerprint record. A keyed audio entry commits to a fixed-size fingerprint record
(hk32/v1 frames of the first 120 s of the marked audio and their validity mask, 15,469 bytes) by its SHA-256 in the
entry's fingerprint field ({alg, record_sha256, record_bytes, duration_s}, no hex). Every keyed audio entry
therefore has one size, and so does every registry read of one. The record is stored beside the entry, written before
it, and content-addressed (Evidence log). Pairing: the record holds AUDIO_FP_RECORD_S = 120 s, the default
watermark.audio.max_sync_duration_s. A keyed configuration with max_sync_duration_s above 120 is refused at start;
raising one needs the other. Confirmation: on every keyed audio call with a chosen payload, detect
fingerprints the searched span (the chosen read's 12 s span widened by 4 s each side, within the file, at most
20 s), reads every slot's record and compares them (equal work above). The fingerprint confirms at T <= 0.33 (the
fingerprint matcher b2-10x14/v1, at least 8 of 10 reliable blocks per window) and only a slot that counts; it guards
against coincidence, not against imitation (Grading). The full tier shows the comparison as fingerprint
on a B (score, spans, windows, hypotheses and false_match_bound_log10); the public tier shows presence only.
Measured figures for the payload are in Robustness and Benchmarks.
Reference
- Configuration: the
watermark.payload_keyssection. - Grading: how a verified tag becomes a grade, and the issuer lines.
- Licensing: shared and private marks, and the Laidline roots.
- Evidence log: the key events and tag fields recorded per entry.
- Robustness and Benchmarks: the measured figures.
- CLI:
laidline payload-keysandlaidline register.