Nineteen signal families run on every document. The half that matters is the benign causes. Every family here fires on honest files, and a queue built without knowing why is a queue your operations team learns to ignore.
Each family is documented with the same four headings. What it detects, the evidence it returns, its benign causes, and its severity logic, and they are grouped onto four pages below, by which part of the file they read.
Signals, not verdicts. Every finding here is a structural fact about a file. "This document was modified after generation" is true or false about the bytes; "this document is fraudulent" is a judgement about a person, and Tamperlens does not make it. The API returns no boolean verdict field, by design.
Severity vocabulary
Four severities, in a strict hierarchy. They describe how much a single finding establishes on its own, not how likely fraud is.
- high On its own establishes that the document changed after generation, or that a tool with editing intent was involved.
- medium A real deviation from "written once by one tool", with common benign explanations.
- low Weak or absence-based; removes a corroborating fact rather than adding one.
- info Context. Not counted in
signalCountand contributes nothing to the score.
One report contains at most one signal per family, that is a
report invariant. A family either fires once, with all its findings collected
into a single evidence object, or it does not appear at all.
Signals are sorted most severe first, then alphabetically by id, so reports are
byte-stable across runs.
One family is an exception, and it is named rather than hidden.
redaction-exposure reports a filled box drawn over text and a
picture drawn over text as two separate signals, because they are not the same
finding: a box is conclusive and scores high, a picture has never once
been shown to be a failed redaction across 1,727 published documents and scores
info. Merging them was the earlier behaviour and it was wrong, it
announced four exposed lines at high severity when three of them were a
letterhead. Every other family obeys the invariant, and a test holds all three
engines to it on every fixture in the repository.
Encrypted documents: what the report tells you it did not do
When the trailer references an /Encrypt dictionary, the strings and
streams in the file are ciphertext. Metadata values, embedded font names and page
content streams cannot be read without the password, which Tamperlens does not
hold and never asks for.
The naive behaviour would be to run every signal family anyway and report whatever fires on the noise. Instead, the five content-dependent families are not run at all and the omission is disclosed as a first-class part of the report:
metadata-mismatchdate-anomaliesproducer-fingerprintfont-anomalieshybrid-page
The disclosure arrives as the structure-warnings signal, titled
"Encrypted document: content not analysed", carrying
evidence.code: "encrypted-content-not-analysed", the explicit
suppressedFamilies list, contentAnalysed: false and
structuralAnalysisRan: true. It rides on
structure-warnings rather than arriving as its own signal because of
the one-signal-per-family invariant.
Structural analysis is unaffected and does run. Revision count, the trailer
/ID pair, signature byte ranges and cross-reference consistency are
all readable without the password, as is the detection of action dictionaries, dictionary keys are not encrypted either. Metadata that happens to read as plain
text still gets reported: a producer may leave the XMP packet in the clear via
/EncryptMetadata false, and a text-likeness gate lets that through
while dropping fields whose bytes are not readable text. Any fields dropped that
way are named in metadataFieldsSuppressed.
Read this carefully when triaging an encrypted file. The absence of a content-level signal in an encrypted document's report says nothing whatsoever about that document. Those checks did not run. Treat the report as structural-only rather than as a clean bill of health, and note that many issuers password-protect statements as a matter of routine, so encryption is not itself a signal.
How signals become a score
summary.riskScore is a weighted aggregation on 0-100, not a
probability and not a confidence. The rules are short enough to state
completely, which is the point. You should be able to reconstruct the number
from the signal list.
-
Weights.
high70,medium25,low10,info0. -
Floors. One
highsignal forces the score to at least 70; onemediumforces at least 30. - Diminishing returns. The first two signals of a given severity contribute their full weight; beyond that each further signal of the same severity contributes a sharply decaying fraction. A pile-up of weak findings converges rather than adding up indefinitely.
-
The high band is reserved. With no
highsignal present the score is clamped to 69, whatever the arithmetic says. SoriskBand === "high"is exactly equivalent to "at least one finding that on its own establishes a change". -
Bands. Below 30
low, 30-69elevated, 70 and abovehigh. -
signalCountcounts signals aboveinfo. An informational finding is visible in the report but invisible to the count and the score.
Weights are versioned by engineVersion: changing them is a version
bump, so a score you recorded last quarter still means what it meant. The engine
is deterministic (identical bytes produce an identical report, apart from the
per-call id), which means you can pin fixtures and regression-test
your own thresholds against us.
Using this honestly
Three habits that separate a useful screening step from an angry-customer generator:
- Baseline before you threshold. Run documents you believe are genuine through the engine, grouped by issuer, and look at which signals fire normally in your population. Producer strings and revision habits vary wildly between institutions.
- Report the fact, not the inference. "This file contains two revisions and names an online PDF editor" is defensible and specific. "Our system flagged this as fraudulent" is neither, and it is the sentence that ends up in a complaint.
- Correlate. A single medium signal is a question. A consumer editor plus a content-overwriting revision plus two subsets of one typeface is a coherent story, and the story is what a human reviewer can act on.
See these families fire on a document
Open the “Saved again,
page changed” sample: a precomputed report where
incremental-updates escalates because a later revision
overwrote a content object, which is the severity rule on this page
with the evidence attached. Your own file goes into
the same checker: it is sent over HTTPS, parsed in
memory and never written to disk. No account, no quota. To run it in
your own pipeline, see the API quickstart.
Tamperlens reports risk signals, not authenticity verdicts. Signals can have benign causes; combine them with your own decision logic.
Related reading
- PDF metadata forensics Where each piece of evidence physically lives in the file format.
- How to detect if a PDF was edited The same checks by hand, with shell commands.
- Bank statement fraud signals Applying the field guide to lending, BNPL, proptech and onboarding flows.
- What defeats every signal A row per family, a column per evasion: measured on a bench, honestly.
- Measuring my own false-positive rate 1,728 documents nobody curated, a 68.5% not-low rate, and the four families it recalibrated.
- API reference Auth, both request forms, the report JSON annotated, quotas.