Backend support for signature rejection was added in #8159, but rejection visibility is not applied consistently to all API responses.
This issue completes the backend contract required by #8162.
Problem
SignatureRejectionVisibilityService already controls when rejection details and rejection comments may be exposed.
However, signer status fields and some response builders can still expose the real rejection state.
For example, File\SignersLoader currently:
- does not expose the
rejection object;
- always exposes the real signer
status;
- always exposes the real
statusText.
With public_status: false, another signer or an anonymous viewer can therefore still receive:
status: 3
statusText: "Rejected"
and discover information that should be hidden.
The validation endpoint also does not currently provide the rejection details required by #8162 to privileged viewers.
Goal
Create one consistent backend visibility contract for signer status and signature rejection.
The backend must:
- keep the real workflow status unchanged;
- expose rejection information only when allowed;
- prevent hidden rejection from being discovered by comparing signer entries;
- provide a safe presentation status for API consumers;
- apply the same rules to normal files and envelopes;
- keep authorization independent from presentation state.
Domain status and display status
SignRequestStatus remains the source of truth for workflow behavior.
Existing domain values remain:
DRAFT = 0
ABLE_TO_SIGN = 1
SIGNED = 2
REJECTED = 3
Do not add privacy or presentation values such as HIDDEN, REDACTED, or NOT_SIGNED to SignRequestStatus.
Add a separate API presentation field:
The allowed values are exactly:
draft
ready_to_sign
signed
rejected
not_signed
displayStatus describes what the current viewer is allowed to know.
It is not used for workflow decisions or authorization.
Normal display status mapping
When signer status is not redacted, map the real signer state as follows:
DRAFT -> draft
ABLE_TO_SIGN -> ready_to_sign
SIGNED -> signed
REJECTED -> rejected
When the real status is allowed to be disclosed, keep the existing numeric status and statusText fields for compatibility.
Hidden rejection
When a file contains a rejection that the current viewer is not allowed to know about, redacting only the rejected signer is not sufficient.
For example:
signer A -> ready_to_sign
signer B -> not_signed
would allow the viewer to identify signer B as the signer whose real status was hidden.
Therefore, when a rejection is hidden:
- every unsigned signer in that file must receive the same public representation;
- signed signers remain unchanged.
For every signer whose real state is:
DRAFT
ABLE_TO_SIGN
REJECTED
return:
{
"displayStatus": "not_signed",
"statusText": "Not signed"
}
Do not include the real numeric status for these redacted signer entries.
For signed signers, keep the normal representation, including:
{
"status": 2,
"displayStatus": "signed"
}
Do not replace a hidden real status with another SignRequestStatus value.
Meaning of not_signed
not_signed is intentionally less specific than the real workflow state.
It means only:
This signer has not signed, and the current viewer is not allowed to know the more specific signer state.
It does not mean:
- draft;
- ready to sign;
- rejected;
- unable to sign;
- able to sign.
It is a presentation state only.
statusText: "Not signed" is kept as a neutral compatibility value for existing API consumers.
Consumers implementing the new contract should use displayStatus for presentation logic.
Rejection object
Use SignatureRejectionVisibilityService as the central place for rejection disclosure rules.
When rejection is visible, the response may include:
{
"status": 3,
"statusText": "Rejected",
"displayStatus": "rejected",
"rejection": {
"rejectedAt": "2026-09-09T12:00:00+00:00"
}
}
When a public comment may be exposed:
{
"status": 3,
"statusText": "Rejected",
"displayStatus": "rejected",
"rejection": {
"rejectedAt": "2026-09-09T12:00:00+00:00",
"comment": "I do not agree with this document",
"commentPrivate": false
}
}
For a privileged viewer, a private comment may be exposed:
{
"status": 3,
"statusText": "Rejected",
"displayStatus": "rejected",
"rejection": {
"rejectedAt": "2026-09-09T12:00:00+00:00",
"comment": "Private reason",
"commentPrivate": true
}
}
A private comment must never be exposed to another viewer.
public_status: true must not override comment privacy.
show_comment_on_validation: true must only expose a comment when it is not private.
Privileged viewers
Privileged viewers are:
- the requester of the signature workflow;
- the signer who rejected, when viewing their own signer entry.
Privilege must come only from trusted backend context.
Reuse the existing resolved response context where possible.
FileResponseOptions already carries information such as:
- authenticated user through
getMe();
- current
SignRequest through getSignRequest();
- completed signer identification through
isSignerIdentified().
Do not create another viewer identity system.
Do not grant privileged visibility based only on:
- request body fields;
- query parameters;
- email;
- display name;
- document UUID;
- signer UUID without the normal identity flow;
- other client-controlled identifiers.
Authorization
displayStatus must never be used for authorization.
All write operations must continue using the real stored workflow state.
In particular:
- a rejected signer remains
SignRequestStatus::REJECTED;
- a rejected signer cannot sign again;
- eligible signers can continue when
cancel_workflow: false;
canSign must use the real state;
- reminders and signer management must use the real state;
- redaction must not modify any entity.
displayStatus exists only for API presentation.
Disclosure boundary
When rejection is hidden, do not expose it through:
- real signer
status;
- rejection-specific
statusText;
rejection;
- rejection timestamp;
- rejection comment;
- rejection privacy flag;
- rejection-specific labels;
- signer summaries;
- differences between unsigned signer states.
Do not change unrelated signer privacy.
Information already normally visible, such as signer identity, remains governed by existing rules.
Response isolation
Visibility is viewer-specific.
Do not store viewer-specific values in:
SignRequest metadata;
- file metadata;
- policy snapshots;
- database state.
Build displayStatus and rejection visibility while preparing the response.
A response generated for a privileged viewer must never be reused for an anonymous or non-privileged viewer.
If responses are cached, the authorization context must be part of the cache boundary.
Response contracts
Update backend response definitions to include the presentation state.
Create a shared response type equivalent to:
SignerDisplayStatus =
draft
ready_to_sign
signed
rejected
not_signed
Review at least:
LibresignSignerSummary
LibresignSignerDetail
LibresignValidatedChildSigner
For response contracts that may be redacted:
displayStatus is required;
- numeric
status is optional because it must be absent when the real unsigned state is redacted;
statusText remains available as a neutral compatibility string.
Generated frontend TypeScript types must remain synchronized with the backend definitions.
Do not make database entities or SignRequestStatus optional or nullable.
Do not add displayStatus to persistence entities.
Response paths
Review all backend paths that serialize or summarize signer state.
At minimum:
File\SignersLoader;
FileListService;
FileService;
FileService::mapSignerDetailsToSummary();
EnvelopeAssembler.
Do not fix only the validation endpoint while another API response continues exposing a hidden rejection.
Validation response
The validation response must support:
- requester;
- rejecting signer after successful signer identification;
- another authenticated signer;
- anonymous viewer.
When rejection is visible:
{
"status": 3,
"statusText": "Rejected",
"displayStatus": "rejected",
"rejection": {
"rejectedAt": "..."
}
}
When rejection is hidden, all unsigned signers of that file receive:
{
"displayStatus": "not_signed",
"statusText": "Not signed"
}
The real numeric status must be absent from those redacted entries.
Detailed file responses
Apply the same visibility contract to detailed file responses.
This includes an authenticated signer who is not the requester.
For the same viewer and same file, validation and detailed-file responses must not reveal contradictory information.
Existing file-list/sidebar consumers must continue to receive a neutral statusText for a redacted signer so the backend change does not introduce an unrelated UI regression.
FileService summaries
FileService::mapSignerDetailsToSummary() currently normalizes signer statuses separately.
Update this path so it does not silently convert REJECTED to DRAFT.
When rejection is visible, preserve the real state and matching displayStatus.
When rejection is hidden:
- all unsigned signer summaries use
displayStatus: "not_signed";
- their numeric
status is omitted;
- their neutral
statusText is "Not signed";
- signed signer summaries keep their real status and use
displayStatus: "signed".
Envelopes
Apply the same contract to envelopes.
For every child file:
- evaluate rejection visibility for that file;
- if rejection is hidden, all unsigned signers of that child file use
not_signed;
- omit their real numeric status;
- signed signers remain unchanged;
- do not allow comparison between signer summaries to identify who rejected.
Do not create envelope-specific privacy rules.
Canceled workflow
FileStatus::CANCELED may remain visible.
When a workflow is canceled and rejection is hidden:
- viewers may know the workflow is canceled;
- they must not be able to determine which signer rejected;
- every unsigned signer uses
displayStatus: "not_signed";
- the real status of those unsigned signers is not exposed;
- signed signers remain
signed.
The file-level canceled state does not authorize disclosure of the rejecting signer.
Shared implementation
Keep the visibility decision centralized in SignatureRejectionVisibilityService or another shared rejection visibility abstraction.
The shared logic should determine at least:
- whether rejection is visible;
- which rejection information may be exposed;
- whether unsigned signer states must be redacted;
- which
displayStatus should be returned;
- whether the numeric status may be exposed.
Do not duplicate these decisions in each serializer.
Tests
This behavior must be covered by automated tests.
Display status mapping
Test every normal mapping:
DRAFT -> draft
ABLE_TO_SIGN -> ready_to_sign
SIGNED -> signed
REJECTED -> rejected
when the real state may be disclosed.
Test not_signed separately as a viewer-specific redacted presentation.
Reject unsupported displayStatus values in generated/frontend contracts.
Hidden rejection
With public_status: false:
- a file with one rejected and one pending signer returns
not_signed for both unsigned signers;
- a file with one rejected, one draft, and one pending signer returns
not_signed for all three;
- redacted unsigned signers do not contain numeric
status;
- redacted unsigned signers receive neutral
statusText: "Not signed";
- signed signers retain their real
SIGNED status and displayStatus: "signed";
- the rejected signer cannot be identified by comparing responses;
- no redacted signer exposes
"Rejected";
- no redacted signer exposes
rejection.
Visible rejection
With rejection visible:
- rejected signer returns
displayStatus: "rejected";
- real
status remains REJECTED;
- requester receives rejection details;
- rejecting signer receives their own rejection details;
- another viewer receives only information permitted by policy.
Comments
Test:
- visible public comment;
- hidden comment when
show_comment_on_validation: false;
- private comment visible to privileged viewer;
- private comment hidden from another viewer;
public_status: true does not override commentPrivate.
Authorization
Test that:
displayStatus does not change canSign;
- absence of numeric
status from a response does not affect backend authorization;
- rejected signer cannot sign again;
- another eligible signer can sign when
cancel_workflow: false;
- client-provided identity fields cannot grant privileged visibility;
- document UUID alone does not grant privilege;
- signer UUID alone does not bypass normal signer identification.
Response isolation
Test sequential requests for the same file:
- requester;
- anonymous viewer;
- rejecting signer;
- another signer.
Verify that each receives the correct viewer-specific response.
Generating one response must not modify:
- persisted signer state;
- file metadata;
- later responses for another viewer.
Response consistency
Cover:
- validation response;
- detailed file response;
- detailed file response as another signer;
- single-file summary;
- envelope child response.
The same viewer must receive equivalent visibility semantics across all paths.
Workflow
Cover both:
cancel_workflow: false
cancel_workflow: true
For continued workflow:
- real eligible signers remain able to sign;
- public presentation may still be
not_signed when needed for privacy.
For canceled workflow:
- file may expose
CANCELED;
- all unsigned signers use the same redacted presentation when rejection is hidden;
- rejecting signer cannot be identified.
Test levels
Use Behat/integration tests for observable API and authorization behavior.
Add explicit validation coverage for:
- anonymous validation;
- identified signer UUID validation;
- requester;
- another authenticated signer.
Use PHPUnit for:
- display status mapping;
- rejection visibility decisions;
- serializer behavior;
- response isolation where appropriate.
Use data providers for mapping and viewer/policy matrices where useful.
Mutation testing
Changed visibility and display-status business logic must be covered by Infection.
For isolated mapping and visibility methods:
- no escaped mutants;
- no mutation errors;
- target 100% MSI.
Do not weaken Infection configuration or exclude production code only to make tests pass.
Quality gates
The implementation must pass:
- PHPUnit;
- Behat/integration tests;
- Psalm;
- PHPCS;
- Infection.
Out of scope
This issue does not implement:
- validation page UI;
- signer Reject UI;
- requester configuration UI;
- Policy Workbench UI.
The validation frontend is handled by #8162.
Backend support for signature rejection was added in #8159, but rejection visibility is not applied consistently to all API responses.
This issue completes the backend contract required by #8162.
Problem
SignatureRejectionVisibilityServicealready controls when rejection details and rejection comments may be exposed.However, signer status fields and some response builders can still expose the real rejection state.
For example,
File\SignersLoadercurrently:rejectionobject;status;statusText.With
public_status: false, another signer or an anonymous viewer can therefore still receive:and discover information that should be hidden.
The validation endpoint also does not currently provide the rejection details required by #8162 to privileged viewers.
Goal
Create one consistent backend visibility contract for signer status and signature rejection.
The backend must:
Domain status and display status
SignRequestStatusremains the source of truth for workflow behavior.Existing domain values remain:
Do not add privacy or presentation values such as
HIDDEN,REDACTED, orNOT_SIGNEDtoSignRequestStatus.Add a separate API presentation field:
The allowed values are exactly:
displayStatusdescribes what the current viewer is allowed to know.It is not used for workflow decisions or authorization.
Normal display status mapping
When signer status is not redacted, map the real signer state as follows:
When the real status is allowed to be disclosed, keep the existing numeric
statusandstatusTextfields for compatibility.Hidden rejection
When a file contains a rejection that the current viewer is not allowed to know about, redacting only the rejected signer is not sufficient.
For example:
would allow the viewer to identify signer B as the signer whose real status was hidden.
Therefore, when a rejection is hidden:
For every signer whose real state is:
return:
{ "displayStatus": "not_signed", "statusText": "Not signed" }Do not include the real numeric
statusfor these redacted signer entries.For signed signers, keep the normal representation, including:
{ "status": 2, "displayStatus": "signed" }Do not replace a hidden real status with another
SignRequestStatusvalue.Meaning of
not_signednot_signedis intentionally less specific than the real workflow state.It means only:
It does not mean:
It is a presentation state only.
statusText: "Not signed"is kept as a neutral compatibility value for existing API consumers.Consumers implementing the new contract should use
displayStatusfor presentation logic.Rejection object
Use
SignatureRejectionVisibilityServiceas the central place for rejection disclosure rules.When rejection is visible, the response may include:
{ "status": 3, "statusText": "Rejected", "displayStatus": "rejected", "rejection": { "rejectedAt": "2026-09-09T12:00:00+00:00" } }When a public comment may be exposed:
{ "status": 3, "statusText": "Rejected", "displayStatus": "rejected", "rejection": { "rejectedAt": "2026-09-09T12:00:00+00:00", "comment": "I do not agree with this document", "commentPrivate": false } }For a privileged viewer, a private comment may be exposed:
{ "status": 3, "statusText": "Rejected", "displayStatus": "rejected", "rejection": { "rejectedAt": "2026-09-09T12:00:00+00:00", "comment": "Private reason", "commentPrivate": true } }A private comment must never be exposed to another viewer.
public_status: truemust not override comment privacy.show_comment_on_validation: truemust only expose a comment when it is not private.Privileged viewers
Privileged viewers are:
Privilege must come only from trusted backend context.
Reuse the existing resolved response context where possible.
FileResponseOptionsalready carries information such as:getMe();SignRequestthroughgetSignRequest();isSignerIdentified().Do not create another viewer identity system.
Do not grant privileged visibility based only on:
Authorization
displayStatusmust never be used for authorization.All write operations must continue using the real stored workflow state.
In particular:
SignRequestStatus::REJECTED;cancel_workflow: false;canSignmust use the real state;displayStatusexists only for API presentation.Disclosure boundary
When rejection is hidden, do not expose it through:
status;statusText;rejection;Do not change unrelated signer privacy.
Information already normally visible, such as signer identity, remains governed by existing rules.
Response isolation
Visibility is viewer-specific.
Do not store viewer-specific values in:
SignRequestmetadata;Build
displayStatusand rejection visibility while preparing the response.A response generated for a privileged viewer must never be reused for an anonymous or non-privileged viewer.
If responses are cached, the authorization context must be part of the cache boundary.
Response contracts
Update backend response definitions to include the presentation state.
Create a shared response type equivalent to:
Review at least:
For response contracts that may be redacted:
displayStatusis required;statusis optional because it must be absent when the real unsigned state is redacted;statusTextremains available as a neutral compatibility string.Generated frontend TypeScript types must remain synchronized with the backend definitions.
Do not make database entities or
SignRequestStatusoptional or nullable.Do not add
displayStatusto persistence entities.Response paths
Review all backend paths that serialize or summarize signer state.
At minimum:
File\SignersLoader;FileListService;FileService;FileService::mapSignerDetailsToSummary();EnvelopeAssembler.Do not fix only the validation endpoint while another API response continues exposing a hidden rejection.
Validation response
The validation response must support:
When rejection is visible:
{ "status": 3, "statusText": "Rejected", "displayStatus": "rejected", "rejection": { "rejectedAt": "..." } }When rejection is hidden, all unsigned signers of that file receive:
{ "displayStatus": "not_signed", "statusText": "Not signed" }The real numeric
statusmust be absent from those redacted entries.Detailed file responses
Apply the same visibility contract to detailed file responses.
This includes an authenticated signer who is not the requester.
For the same viewer and same file, validation and detailed-file responses must not reveal contradictory information.
Existing file-list/sidebar consumers must continue to receive a neutral
statusTextfor a redacted signer so the backend change does not introduce an unrelated UI regression.FileService summaries
FileService::mapSignerDetailsToSummary()currently normalizes signer statuses separately.Update this path so it does not silently convert
REJECTEDtoDRAFT.When rejection is visible, preserve the real state and matching
displayStatus.When rejection is hidden:
displayStatus: "not_signed";statusis omitted;statusTextis"Not signed";displayStatus: "signed".Envelopes
Apply the same contract to envelopes.
For every child file:
not_signed;Do not create envelope-specific privacy rules.
Canceled workflow
FileStatus::CANCELEDmay remain visible.When a workflow is canceled and rejection is hidden:
displayStatus: "not_signed";signed.The file-level canceled state does not authorize disclosure of the rejecting signer.
Shared implementation
Keep the visibility decision centralized in
SignatureRejectionVisibilityServiceor another shared rejection visibility abstraction.The shared logic should determine at least:
displayStatusshould be returned;Do not duplicate these decisions in each serializer.
Tests
This behavior must be covered by automated tests.
Display status mapping
Test every normal mapping:
when the real state may be disclosed.
Test
not_signedseparately as a viewer-specific redacted presentation.Reject unsupported
displayStatusvalues in generated/frontend contracts.Hidden rejection
With
public_status: false:not_signedfor both unsigned signers;not_signedfor all three;status;statusText: "Not signed";SIGNEDstatus anddisplayStatus: "signed";"Rejected";rejection.Visible rejection
With rejection visible:
displayStatus: "rejected";statusremainsREJECTED;Comments
Test:
show_comment_on_validation: false;public_status: truedoes not overridecommentPrivate.Authorization
Test that:
displayStatusdoes not changecanSign;statusfrom a response does not affect backend authorization;cancel_workflow: false;Response isolation
Test sequential requests for the same file:
Verify that each receives the correct viewer-specific response.
Generating one response must not modify:
Response consistency
Cover:
The same viewer must receive equivalent visibility semantics across all paths.
Workflow
Cover both:
For continued workflow:
not_signedwhen needed for privacy.For canceled workflow:
CANCELED;Test levels
Use Behat/integration tests for observable API and authorization behavior.
Add explicit validation coverage for:
Use PHPUnit for:
Use data providers for mapping and viewer/policy matrices where useful.
Mutation testing
Changed visibility and display-status business logic must be covered by Infection.
For isolated mapping and visibility methods:
Do not weaken Infection configuration or exclude production code only to make tests pass.
Quality gates
The implementation must pass:
Out of scope
This issue does not implement:
The validation frontend is handled by #8162.