PDFlibPas answers the FieldMDP question through TPDFlib.GetSignatureModificationVerdict. Given a signed PDF, its password and a signature field name, the method walks every incremental update appended after that signature, resolves the fully qualified name of each form field those updates touched, and returns 3 when a field locked by FieldMDP was changed and 2 when only unlocked fields were. GetSignatureModificationDetail then names the revision and the field
The workflow that needs this is the multi-approver form. A requester fills Order.Customer and Order.Total on a purchase request and signs Approval.Requester, locking those two fields. A manager adds a cost center and signs Approval.Manager. Two weeks later somebody opens the file in a desktop editor and nudges Order.Total upward in a third incremental update. Every signature still verifies, because each one covers only its own byte range. The questions an auditor actually asks are which field changed after which signature, and whether that change was allowed
Why can DocMDP alone not catch an edited locked field?
DocMDP cannot catch it because DocMDP classifies kinds of change, not individual fields. The certification permission levels in ISO 32000-1 §12.8.2.2 say whether later updates may fill forms and sign (P 2) or also annotate (P 3), and that permission applies to every field in the document equally. Editing Order.Total is form filling, so a P 2 certification is satisfied by exactly the edit the requester meant to forbid. The document-level modification classification in PDFlibPas covers that layer, and this article goes one step finer
FieldMDP is the field-granular layer. A signature field can carry a /Lock dictionary (ISO 32000-1 §12.7.4.5), and the signature it holds can carry a FieldMDP transform in its signature reference (ISO 32000-1 §12.8.2.4). Both use the same vocabulary: /Action /All locks every field, /Action /Include locks the names listed in /Fields, and /Action /Exclude locks everything except the names in /Fields. Before v3.586.0, GetSignatureModificationVerdict knew that a later revision had changed a form field but not which one, so any document carrying a field lock whose later updates went beyond validation material came back as 4, undetermined. The revision map now records a resolved field name for every field-related object it classifies, which is what turns that 4 into a real 2 or 3
Writing the field lock when each approver signs
TPDFlib.SetSignProcessFieldLock sets the lock for the next signature a signing process produces. Action 1 is All, 2 is Include, 3 is Exclude and 0 clears the lock; Include and Exclude take a semicolon-separated list of fully qualified field names. The signed field ends up with a /Lock dictionary and a matching FieldMDP transform, which is the pairing strict validators expect
var
Pdf: TPDFlib;
SignId: Integer;
begin
Pdf := TPDFlib.Create;
try
SignId := Pdf.NewSignProcessFromFile('purchase-request.pdf', '');
if SignId = 0 then
raise Exception.Create('cannot open the form');
try
Pdf.SetSignProcessField(SignId, 'Approval.Requester');
Pdf.SetSignProcessPFXFromFile(SignId, 'requester.pfx', PfxPassword);
// 2 = Include: only these fields are frozen by this signature
if Pdf.SetSignProcessFieldLock(SignId, 2,
'Order.Customer;Order.Total') <> 1 then
raise Exception.Create('field lock rejected');
Pdf.EndSignProcessToFile(SignId, 'purchase-request-signed.pdf');
if Pdf.GetSignProcessResult(SignId) <> 1 then
raise Exception.CreateFmt('signing failed, code %d',
[Pdf.GetSignProcessResult(SignId)]);
finally
Pdf.ReleaseSignProcess(SignId);
end;
finally
Pdf.Free;
end;
end;
SetSignProcessFieldLock returns 0 for an Include or Exclude call with no names, and also for a list containing an empty entry such as 'Order.Customer;;Order.Total'; a rejected call leaves the previously configured lock in place, so check the return value rather than assuming the last call won. The rest of the signing setup, digest choice, /Contents reservation and result codes, is covered in the PAdES signing and validation guide for PDFlibPas. One design point is specific to approval chains: an /All lock on the requester signature also freezes Approval.Manager, because a signature field is a form field too. Use Include for the data fields each approver owns, or Exclude with the later signature fields listed
Running the verdict across an approval chain
GetSignatureModificationVerdict judges one signature at a time, so an approval chain is checked by calling it once per signature field. The return value is a small, closed set, and each value means something different for a reviewer:
- 0: the file or the named signature field could not be read
- 1: nothing was appended after the signature
- 2: only changes the effective permissions allow were appended, and no locked field was touched
- 3: a later revision made a change that is not allowed, including an edit to a field FieldMDP locks
- 4: the answer could not be determined, which must never be read as approval
- 5: the signature does not end on a revision boundary, so bytes sit inside the range it claims to cover
const
Approvals: array[0..2] of WideString =
('Approval.Requester', 'Approval.Manager', 'Approval.Finance');
var
Pdf: TPDFlib;
I, Verdict: Integer;
begin
Pdf := TPDFlib.Create;
try
for I := Low(Approvals) to High(Approvals) do
begin
Verdict := Pdf.GetSignatureModificationVerdict(
'purchase-request-final.pdf', FormPassword, Approvals[I]);
// both getters describe the call just made, so read them before the next one
Log.Add(Format('%s: verdict %d, MDP level %d, %s',
[Approvals[I], Verdict, Pdf.GetSignatureEffectiveMDPLevel,
Pdf.GetSignatureModificationDetail]));
case Verdict of
1, 2: ; // accepted
3: RejectForm(Approvals[I]); // detail names field and revision
else
QueueForManualReview(Approvals[I]); // 0, 4, 5
end;
end;
finally
Pdf.Free;
end;
end;
Take the tampered purchase request, where revision 0 is the blank form, revisions 1 and 2 are the two signatures and revision 3 is the edit. Because the lock definitions are pooled from every signed field, the verdict for Approval.Manager already enforces the requester lock, and its only later update is the edit itself. When that update rewrote just the field dictionary, the verdict is 3 and the detail reads Revision 3 changed locked form field "Order.Total", which FieldMDP does not allow. That one line answers both audit questions. The verdict for Approval.Requester has to get past the manager signing revision first, and for the reasons in the limitations section below it will often report 4, so the most recent signature usually gives the sharpest answer. GetSignatureEffectiveMDPLevel reports the lowest DocMDP P value found among the signatures, or 3 when nothing certified the document, and both getters stay valid only until the next verdict call
How does PDFlibPas know which field a revision changed?
PDFlibPas reconstructs the field name from the object graph of each revision, because a changed object rarely carries its full name. A field dictionary with /FT or /V, or a widget annotation, holds only its partial name in /T. The fully qualified name described in ISO 32000-1 §12.7.3.2 is the chain of partial names from the root, joined with periods, so the revision map walks /Parent links upward and prepends each /T it finds. Each parent is read from its newest definition at or before the revision being classified, which keeps a later rename of a parent node from rewriting the history of earlier edits
Matching the resolved name against the locks is deliberately simple. A name matches a /Fields entry when it equals the entry exactly or starts with the entry followed by a period, so locking Order also locks Order.Total and every other descendant. All locks every name, Include locks the matching names, and Exclude locks every name that does not match. The lock definitions come from every signed signature field in the file; where a field has both a /Lock dictionary and a FieldMDP transform, the /Lock values are used. To see exactly what each signature locked, the lower-level TPDFlibSignDoc class from the PDFlibDigitalSign unit exposes the permission report the verdict reads:
uses
PDFlibDigitalSign;
var
SignDoc: TPDFlibSignDoc;
Report: TPDFlibSignaturePermissionReport;
Names: TStringList;
I, J: Integer;
Line: string;
begin
SignDoc := TPDFlibSignDoc.Create;
Names := TStringList.Create;
try
if not SignDoc.Open('purchase-request-final.pdf', FormPassword, False) then
Exit;
try
SignDoc.GetSignatureFieldNames(Names);
for I := 0 to Names.Count - 1 do
begin
SignDoc.GetSignaturePermissionInfoByName(Names[I], Report);
if not (Report.HasFieldLock or Report.HasFieldMDP) then
Continue;
Line := Names[I] + ': ' + string(Report.FieldLockAction);
for J := 0 to High(Report.FieldNames) do
Line := Line + ' ' + string(Report.FieldNames[J]);
if Report.HasFieldLock and Report.HasFieldMDP and
not Report.FieldMDPMatches then
Line := Line + ' (lock and FieldMDP transform disagree)';
Log.Add(Line);
end;
finally
SignDoc.Close;
end;
finally
Names.Free;
SignDoc.Free;
end;
end;
Encrypted forms: pass the password or the names are unreadable
An encrypted form has to be decrypted before any field name can be resolved, so GetSignatureModificationVerdict needs the Password argument for more than opening the file. Strings such as /T are encrypted per object, and fields written into compressed object streams cannot be read at all until the containing stream is decrypted and inflated. Since v3.586.0 the internal revision map accepts the password, attaches a decryption context while it parses object streams and revision dictionaries, and decrypts every object it decodes, including the earlier versions it diffs against and the parents it walks
The per-object decryption in the revision map switches on only when Password is non-empty. A verdict computed on an encrypted file with an empty password should therefore be treated as unverified, even if the document itself opened without prompting. Store the form password next to the workflow record and pass it on every call
What the FieldMDP verdict does not cover
The field check is a structural answer to one narrow question, and several cases fall outside it. Knowing them in advance is what keeps a 4 from turning into a support escalation:
- The field comparison runs only after the document-level check has come out clean and at least one later revision changed more than validation material or time stamps; a DocMDP violation is reported as 3 before any field is looked at
- Objects classified as signature-related but carrying no field name, such as the signature value dictionary, a rewritten AcroForm dictionary, or a catalog change limited to
/AcroFormor/Perms, make the verdict return 4 rather than guess, so a revision that adds a later approval signature under an active lock often lands on 4 - Only field dictionaries and widget annotations get a field name; the revision map has no dedicated class for appearance streams, so an editor that regenerates appearances along with the value can be stopped by the document-level check as a content change before the field check runs, and the detail string tells the two cases apart
- The scan stops at the first locked field or unnamed entry it meets, so a 3 names one offending field, not all of them
- Locks are pooled from every signed signature field without ordering them in time, so a lock written by a later signer is also applied to updates that came before it; this can only push a result toward 3, never toward 2
- Names are compared exactly and case-sensitively, and the
/Parentwalk stops after eight levels, so an unusually deep hierarchy resolves to a truncated name - A FieldMDP entry whose structure does not validate and whose action cannot be read makes the verdict return 4
When a verdict comes back as 4, GetSignatureRevisionIndex gives the revision the signature ends at and GetDocumentRevisionChanges lists every object each later revision added, modified or freed, with its change class. That is enough for a person to decide in a minute. The mechanics of how those revisions get appended are covered in the incremental update guide for PDFlibPas
FAQ
Does a verdict of 2 mean the signatures are valid?
No. GetSignatureModificationVerdict examines what was appended after a signature and never checks the CMS signature value, the certificate chain or revocation. A 2 says the later updates stayed inside the permissions; cryptographic validation is a separate step and both are needed
Why does a second approver signing produce 4 instead of 2?
Signing appends a signature value dictionary and usually rewrites form bookkeeping such as the AcroForm dictionary. Those objects belong to no named field, and when a field lock is active the verdict refuses to clear a change it cannot attribute to a field. Use GetDocumentRevisionChanges to confirm that the revision contains only the expected signature objects
Can one name lock a whole group of fields?
Yes. A lock entry matches the named field and every descendant whose fully qualified name starts with it followed by a period, so listing Order in an Include lock freezes Order.Customer, Order.Total and any other field under that parent
FieldMDP verdicts, field lock signing and revision inspection all ship in the PDFlibPas Delphi PDF Library, together with the DocMDP certification and PAdES long-term validation features the approval workflow above relies on