Skip to content

Apply signature rejection visibility rules to signer status and validation API #8388

Description

@vitormattos

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:

displayStatus

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:

  1. requester;
  2. anonymous viewer;
  3. rejecting signer;
  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    backendBackend taskbugSomething isn't workingphpPull requests that update Php code

    Type

    Projects

    • Status
      0. Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions