A signed PDF that changed after signing is not automatically broken. ISO 32000-1 permits incremental updates on top of a signature, and only some of them break the policy the signer set. HotPDF Component for Delphi and C++Builder answers that question with AnalyzeLoadedSignatureRevisions, which classifies every post-signature revision and grades it against DocMDP and FieldMDP. The scenario is familiar to anyone shipping contract software: your customer signs a purchase agreement, sends it out, and gets it back with an annex page attached. The reader shows a yellow bar saying the signature is intact but the document has been changed since it was signed, and nobody in the room can say whether that is a normal countersignature workflow or someone quietly editing a signed contract
What counts as a legal change after signing?
A change is legal when its semantic category falls inside the permission the certifying signature declared. ISO 32000-1 §12.8.2.2 defines the DocMDP transform with a /P value of 1, 2 or 3: 1 permits no changes at all, 2 permits form filling and signing, 3 permits form filling, signing and annotations. HotPDF exposes those as THPDFDocMDPPermission values dmpNoChanges, dmpFormFillAndSign and dmpFormFillSignAndAnnotate, with dmpNone reserved for inspection results carrying no DocMDP transform at all
The categories are ordered, and that ordering is the engine of the whole check. THPDFRevisionModificationLevel runs rmlNone, rmlLongTermValidation, rmlFormFillAndSign, rmlAnnotations, rmlOther, deliberately arranged so a larger ordinal is never less restrictive. A whole document reduces to the maximum level observed across every revision after the signature, and the DocMDP comparison becomes a single integer test. One nuance matters early: at dmpNoChanges the analysis still accepts rmlLongTermValidation. Adding DSS and VRI validation material or a document timestamp to a certified file is maintenance of the signature, not modification of the document, and treating it as a violation would break every long-term archival workflow in existence
How does HotPDF rebuild the revision chain?
Structurally, not heuristically. Per ISO 32000-1 §7.5.6 an incremental update appends a new cross-reference section whose /Prev points at the previous one, so HotPDF reads startxref from the tail, parses the section there, follows /Prev backwards and repeats, returning the sections oldest first. Two safety limits sit in that loop and both are worth knowing when triaging a file that fails: a /Prev pointing at an offset already visited terminates the walk with an explicit cycle diagnostic instead of spinning, and a chain longer than a thousand revisions is rejected outright. Both surface in Analysis.Issue with the function returning False, and neither should be papered over, because a cyclic /Prev is a malformed or hostile file rather than an unusual one
Four historical shapes turn up in real documents and all four are handled: traditional xref tables parsed line by line, cross-reference streams decompressed and decoded through their /W and /Index fields, hybrid-reference files whose traditional trailer carries a /XRefStm key that gets parsed and merged into the same revision (the Office producer case, covered in the article on hybrid cross-reference streams), and objects living inside an ObjStm container, which matter because a modern update usually puts the changed dictionary in a compressed stream rather than writing it directly, as described in the piece on object streams and incremental updates. The signature anchors the split: /ByteRange[2] + /ByteRange[3] becomes SignedRevisionLength, and every section at or beyond that offset is post-signature. Whether the byte range still hashes correctly is a separate question, answered by VerifyLoadedSignature and covered in the article on verifying PDF digital signatures
How each changed object gets classified
Classification runs per object, then propagates along references. For every object number a post-signature section touches, HotPDF reads the new body and the body as it stood in the signed snapshot; an identical body is rmlNone, because producers do rewrite objects without changing them. The recognisers are narrow on purpose. A /Type /DocTimeStamp object, or one whose /SubFilter is ETSI.RFC3161, is rmlLongTermValidation, as is anything reachable from the catalog /DSS tree; a /Type /Sig dictionary is rmlFormFillAndSign. For containers the test is which keys moved, not what the object is: the catalog may only gain or alter /DSS, /Extensions or /AcroForm; the AcroForm dictionary only /Fields, /SigFlags, /NeedAppearances, /DR, /DA or /Q; a page only /Annots; a field or widget only /V, /AP, /AS or /M. Anything outside those sets drops to rmlOther, which is exactly how the appended annex page gets caught: adding a page rearranges the page tree in ways no whitelist covers, and no amount of legitimate form filling resembles it
Then the levels propagate, with each container inheriting the maximum level of the changed children it points at, iterated until the assignment stabilises. This is what makes appearance streams work. A filled text field rewrites /V and points at a fresh /AP stream, and that stream on its own is an anonymous blob of content operators with no type to recognise; because the field that owns it is rmlFormFillAndSign, the stream inherits the same level instead of falling through to rmlOther. The same propagation carries DSS context onto certificate and revocation streams that would otherwise be unclassifiable
Why does an unreadable object count as a violation?
Because the alternative is a validator defeated by writing something it does not understand. Three situations end at rmlOther without appeal in HotPDF: an object whose body could not be read from the revision, an object the revision marks as freed, and an object matching none of the recognisers above. Each records a specific diagnostic in the revision Issue field, so an operator can see which object number produced the verdict
Freeing is the sharpest of the three. A post-signature revision that marks a previously defined object as free has deleted content from a signed document, and no permission level under §12.8.2.2 allows it; the object numbers land in FreedObjectNumbers and the revision is raised to rmlOther. Unreadable objects follow the same logic for a different reason. A validator that cannot parse an object has no basis for calling it harmless, and the honest response to that is not silence. Reporting an unusual but benign construct as a violation costs a human review; the opposite mistake ships a signed contract with an unnoticed edit inside it
Reading the verdict in Delphi
The call is short. Load the document, pick a signature index, read the record; the parameterless overload reopens the file the document was loaded from, and the TStream overload takes caller-supplied bytes and restores the stream position before returning. PolicyCompliant is the single boolean most callers want, combining three independent decisions: the structural validity of the permission dictionaries, DocMDPCompliant, and FieldMDPCompliant. Keep the components visible in your UI rather than collapsing them, and note that a document with no DocMDP transform leaves DocMDPCompliant at True, since an ordinary approval signature declares no policy to violate and the aggregate ModificationLevel is then descriptive rather than a verdict
var
Pdf: THotPDF;
Analysis: THPDFSignatureRevisionAnalysis;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('contract-countersigned.pdf') > 0 then
begin
if Pdf.AnalyzeLoadedSignatureRevisions(0, Analysis) then
begin
if Analysis.PolicyCompliant then
Writeln('Post-signature changes stay inside the signing policy')
else
Writeln('Policy violation: ', string(Analysis.Issue));
end
else
Writeln('Analysis could not run: ', string(Analysis.Issue));
end;
finally
Pdf.Free;
end;
end;
For triage you usually want the per-revision breakdown instead of the summary, because it says when in the document history things went wrong. Each entry in Analysis.Revisions carries its index in the chain, the cross-reference offset it was written at, its own modification level, and the object numbers involved
const
LevelNames: array[THPDFRevisionModificationLevel] of string =
('none', 'long-term validation', 'form fill and sign',
'annotations', 'other');
var
I: Integer;
begin
Writeln(Format('%d revisions in chain, signature sits at index %d',
[Analysis.TotalRevisionCount, Analysis.SignedRevisionIndex]));
for I := 0 to High(Analysis.Revisions) do
Writeln(Format(' rev %d at offset %d: %s (%d changed, %d freed) %s',
[Analysis.Revisions[I].RevisionIndex,
Analysis.Revisions[I].XRefOffset,
LevelNames[Analysis.Revisions[I].ModificationLevel],
Length(Analysis.Revisions[I].ChangedObjectNumbers),
Length(Analysis.Revisions[I].FreedObjectNumbers),
string(Analysis.Revisions[I].Issue)]));
end;
FieldMDP is judged separately, and that is deliberate
A document can satisfy DocMDP and still be illegitimate, which is why FieldMDPCompliant is a distinct boolean rather than folded into the level comparison. ISO 32000-1 §12.8.2.4 defines the FieldMDP transform, and §12.7.5.5 the related /SigFieldLock entry, to freeze named form fields at the moment of signing even where the document as a whole still permits form filling. Filling a field is a level 2 action; filling a field the signer locked is a violation regardless of level. HotPDF reads the scope into THPDFFieldLockAction as flaAll, flaInclude or flaExclude, with flaNone for results carrying no lock policy, and the names into Permissions.FieldNames: flaAll locks everything, flaInclude locks the listed names, flaExclude locks everything except them. One detail matters when reading the results, in that only fields already present in the signed snapshot are reported in ChangedFieldNames, because a field created entirely after signing has no signed state to contradict and is caught by the DocMDP path instead
var
Source: TFileStream;
Analysis: THPDFSignatureRevisionAnalysis;
I: Integer;
begin
Source := TFileStream.Create('contract.pdf', fmOpenRead or fmShareDenyWrite);
try
if Pdf.AnalyzeLoadedSignatureRevisions(0, Source, Analysis) then
if Analysis.Permissions.HasFieldMDP and (not Analysis.FieldMDPCompliant) then
for I := 0 to High(Analysis.ChangedFieldNames) do
Writeln('modified after locking: ',
string(Analysis.ChangedFieldNames[I]));
finally
Source.Free; // stream position was restored before the call returned
end;
end;
What this analysis will not tell you
It does not verify a signature. AnalyzeLoadedSignatureRevisions reasons about structure and permissions; whether the signed byte range still hashes to the value in the CMS blob, and whether the signer certificate chains to anything you trust, are answered by VerifyLoadedSignature and VerifyLoadedSignatureWithTrust. A file can be perfectly policy-compliant and cryptographically worthless, so the two checks belong side by side in any real acceptance gate. It also does not read intent inside content streams: a page whose content stream was replaced wholesale is caught as a change outside the whitelist, but the analysis will not tell you the replacement swapped a payment figure. An rmlOther verdict means a human should look, not that fraud occurred, and a compliant verdict means the change fits an allowed category, not that the change was wanted. When all you need is what the signer declared, without the revision walk, GetLoadedSignaturePermissions returns the policy dictionaries on their own
Everything described here runs natively in Delphi and C++Builder with no external signing service in the loop, which is what makes it practical to run on every inbound document instead of only the ones somebody already suspected. The full signature and revision API, including the permission and verification methods it pairs with, is part of the HotPDF Component for Delphi and C++Builder