Skip to content

Define workflow outcome when all signing participants reject with continue behavior #8915

Open

Description

@vitormattos

Part of #7832.

Context

#8405 defined the final rejection workflow contract and #8706 implemented rejection_behavior = continue.

With continue, a file reaches FileStatus::SIGNED when every signing participant is either SIGNED or REJECTED and at least one signing participant is SIGNED.

This leaves one case without a final workflow outcome:

A -> REJECTED
B -> REJECTED
C -> REJECTED

rejection_behavior -> continue

Today SignFileService::workflowCompletedDespiteRejections() returns false when no signature was applied. The workflow therefore does not reach a final signed state, but the expected file-level result is not defined.

Goal

Define and implement the file-level outcome when all signing participants reject a workflow configured with:

rejection_behavior = continue

The result must clearly represent that no participant is still expected to sign, no signature was applied, and every signing participant keeps the real REJECTED state.

Requirements

The implementation must define one explicit terminal behavior for this case.

Do not:

  • convert rejected signers to SIGNED;
  • use displayStatus for workflow decisions;
  • let observers affect signing completion;
  • solve the problem only in the frontend.

Prefer an existing FileStatus when its current meaning is correct.

If no existing status represents this case correctly, document the domain impact before adding a new status because it would affect API responses, validation, envelopes, filters and frontend consumers.

The same rule must work for:

  • normal files;
  • envelopes;
  • parallel signing;
  • sequential signing.

Acceptance criteria

  • A workflow where every signing participant rejects reaches a defined and stable file-level outcome.
  • The file is not reported as SIGNED when no signature was applied.
  • Every rejecting participant remains SignRequestStatus::REJECTED.
  • No signer remains actionable after all signing participants reject.
  • Observers do not block the final outcome.
  • Normal files and envelopes follow the same rule.
  • Sequential and parallel signing are covered.
  • Existing continue behavior with at least one signed participant still results in FileStatus::SIGNED.
  • Existing cancel behavior is unchanged.

Tests

Cover at least:

  • one signer, rejected;
  • multiple signers, all rejected;
  • signed and rejected participants;
  • all rejected plus observer;
  • sequential flow ending with all signing participants rejected;
  • envelope children reaching the same outcome.

Use unit tests for status evaluation and integration coverage for the observable workflow result.

Related

#8405
#8706

Out of scope

  • rejection visibility;
  • requester or signer UI;
  • Policy Workbench configuration;
  • changes to rejection_behavior = cancel.

Activity

  1. maia-andre commented on Oct 1, 2026

    @maia-andre
    Contributor

    I'd like to take this one, since it is the open question I left in the #8706 description.

    My proposal is to reuse FileStatus::CANCELED. Its current meaning is already "the signing workflow was closed because a signer rejected the signature request", which describes this case: nobody is left to sign and no signature was applied. With it, no new status reaches the API, validation, filters or the frontend, and every participant keeps its real REJECTED state.

    The rule would sit next to workflowCompletedDespiteRejections(): under continue, if every signing participant is REJECTED (observers ignored), the file goes to CANCELED; otherwise the current behavior stays. Envelopes follow the same rule per child, and the envelope status is derived as it is today.

    If you would rather keep CANCELED only for the cancel behavior, I'll describe the impact of a new status here first before writing any code.

  2. vitormattos commented on Oct 1, 2026

    @vitormattos
    MemberAuthor

    Reusing FileStatus::CANCELED makes sense to me. I don't think we need a new status for this case.

    My only concern is where this is handled. workflowCompletedDespiteRejections() is evaluated from the signing flow, so if the last remaining participant rejects, there is no later signature to trigger that status evaluation.

    I think the final status needs to be evaluated from the rejection flow as well, preferably reusing the same status resolution logic instead of adding a separate rule there.

    We also need to check envelopes. EnvelopeStatusDeterminer currently treats zero signatures as ABLE_TO_SIGN, so an envelope where everyone rejected would not become terminal with the current logic.

    There is also one consistency point to verify: workflowCanceled in the rejection event currently comes from rejection_behavior, while the API response derives it from FileStatus::CANCELED. If continue + all rejected becomes CANCELED, those two results should not disagree.

  3. maia-andre commented on Oct 1, 2026

    @maia-andre
    Contributor

    Thanks, I went through the three points before writing code. They go a bit further than the all-rejected case, so I'd like to split this into two PRs.

    1. Status evaluated from the rejection flow. You were right. Today the rejection flow never re-evaluates the file status under continue. This affects more than "everyone rejects": if A signs and then B rejects as the last pending signer, the file stays PARTIAL_SIGNED forever. I reproduced it with a Behat scenario (status 2 after the last rejection). reject.feature only covers the opposite order (reject first, sign later).

    2. Envelopes. There are two places that derive an envelope status, and neither one knows about REJECTED or CANCELED:

    • EnvelopeStatusDeterminer counts only signatures: zero signed is ABLE_TO_SIGN, and signed plus rejected is PARTIAL_SIGNED.
    • FileStatusService::propagateStatusToParent() compares the numeric min/max of the children, so a CANCELED child (6) makes the parent PARTIAL_SIGNED. The cancel behavior only escapes this because cancelWorkflow() writes CANCELED to the envelope afterwards.

    3. workflowCanceled. The event takes it from rejection_behavior, and the API takes it from FileStatus::CANCELED. The plan is to build the event value from the resulting file status after the transaction, so both always agree. SignatureRejectedEvent::wasWorkflowCanceled() has no consumer inside the app.

    Proposed split

    • PR 1: move the status rule out of SignFileService into a SigningWorkflowStatusResolver, with no behavior change. The rejection flow then calls it, inside the same transaction, for every document the rejection touched (not the envelope itself, whose status follows its documents). This fixes the signed-then-rejected case above. A rejection that completes the workflow does not dispatch SignedEvent. That is intended: the event is per signature, and nothing listens to it as "workflow finished".
    • PR 2: the resolver returns CANCELED when every signing participant rejected under continue. Both envelope paths learn about REJECTED/CANCELED. workflowCanceled is derived from the final status. Behat covers the cases listed in the issue: single and multiple signers, observer, sequential, envelope.

    One decision for you: an envelope where one document ends SIGNED and another ends CANCELED (for example, different signers per document, and everyone on the second one rejected). My proposal is that the envelope is SIGNED when at least one signature was applied and nobody is pending, and CANCELED when no signature was applied at all. Does that match what you expect?

    PR 1 is stacked on #8948 because both touch persistRejection(). I'll rebase it as soon as #8948 is merged.

  4. vitormattos commented on Oct 5, 2026

    @vitormattos
    MemberAuthor

    Thanks. I checked this again with the current main, and I also compared this behavior with other signing systems.

    The split into two PRs makes sense to me.

    For PR 1, I only suggest making clear that calling the resolver from the rejection flow is also a bug fix. Today, if A signs and B is the last signer and rejects, the file can stay PARTIAL_SIGNED.

    For the envelope case, I agree with your proposal.

    After #8706, LibreSign already uses this rule for one document: when all signing participants are done (SIGNED or REJECTED) and at least one signature exists, the file becomes SIGNED.

    So I think the envelope should follow the same rule:

    • no signatures and all signing participants rejected -> CANCELED
    • at least one signature and nobody is pending -> SIGNED
    • someone can still sign -> keep the normal non-final status

    This is also important for the meaning of rejection_behavior = continue. If one document is signed and another document has all its signers reject, marking the whole envelope as CANCELED would make the rejection cancel the envelope in the end.

    I don't think PARTIAL_SIGNED is a good terminal status here either, because today it also means that the signing workflow is not finished.

    There is one thing I think we should fix in this work. The comment for FileStatus::SIGNED still says "when all required signatures are complete". This is not the full meaning anymore after #8706. A workflow can finish as SIGNED with signed and rejected participants.

    Maybe we should describe it as a completed signing workflow where at least one signature was applied and no signing participant is still pending.

    For envelopes, I would also add an explicit test for:

    document A -> SIGNED
    document B -> CANCELED
    envelope   -> SIGNED
    

    This case is important because it defines the behavior we are choosing for continue.

    The plan for workflowCanceled also looks good. Using the final status should keep the event and API consistent.

    And since #8948 is still open and both changes touch persistRejection(), rebasing after it is merged looks right to me.

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

Metadata

Metadata

Assignees

Labels

backendBackend workenhancementApp improvimentsphpWork involving PHP code

Type

No type

Fields

Priority

None yet

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions