Repository navigation
Define workflow outcome when all signing participants reject with continue behavior #8915
Description
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
displayStatusfor 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
SIGNEDwhen 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
continuebehavior with at least one signed participant still results inFileStatus::SIGNED. - Existing
cancelbehavior 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
Out of scope
- rejection visibility;
- requester or signer UI;
- Policy Workbench configuration;
- changes to
rejection_behavior = cancel.
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Projects
- StatusShow more project fieldsNo status
Activity
maia-andre commentedon Oct 1, 2026
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 realREJECTEDstate.The rule would sit next to
workflowCompletedDespiteRejections(): undercontinue, if every signing participant isREJECTED(observers ignored), the file goes toCANCELED; 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
CANCELEDonly for thecancelbehavior, I'll describe the impact of a new status here first before writing any code.vitormattos commentedon Oct 1, 2026
Reusing
FileStatus::CANCELEDmakes 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.
EnvelopeStatusDeterminercurrently treats zero signatures asABLE_TO_SIGN, so an envelope where everyone rejected would not become terminal with the current logic.There is also one consistency point to verify:
workflowCanceledin the rejection event currently comes fromrejection_behavior, while the API response derives it fromFileStatus::CANCELED. Ifcontinue+ all rejected becomesCANCELED, those two results should not disagree.maia-andre commentedon Oct 1, 2026
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 staysPARTIAL_SIGNEDforever. I reproduced it with a Behat scenario (status2after the last rejection).reject.featureonly covers the opposite order (reject first, sign later).2. Envelopes. There are two places that derive an envelope status, and neither one knows about
REJECTEDorCANCELED:EnvelopeStatusDeterminercounts only signatures: zero signed isABLE_TO_SIGN, and signed plus rejected isPARTIAL_SIGNED.FileStatusService::propagateStatusToParent()compares the numeric min/max of the children, so aCANCELEDchild (6) makes the parentPARTIAL_SIGNED. Thecancelbehavior only escapes this becausecancelWorkflow()writesCANCELEDto the envelope afterwards.3.
workflowCanceled. The event takes it fromrejection_behavior, and the API takes it fromFileStatus::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
SignFileServiceinto aSigningWorkflowStatusResolver, 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 dispatchSignedEvent. That is intended: the event is per signature, and nothing listens to it as "workflow finished".CANCELEDwhen every signing participant rejected undercontinue. Both envelope paths learn aboutREJECTED/CANCELED.workflowCanceledis 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
SIGNEDand another endsCANCELED(for example, different signers per document, and everyone on the second one rejected). My proposal is that the envelope isSIGNEDwhen at least one signature was applied and nobody is pending, andCANCELEDwhen 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.vitormattos commentedon Oct 5, 2026
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 (
SIGNEDorREJECTED) and at least one signature exists, the file becomesSIGNED.So I think the envelope should follow the same rule:
CANCELEDSIGNEDThis 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 asCANCELEDwould make the rejection cancel the envelope in the end.I don't think
PARTIAL_SIGNEDis 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::SIGNEDstill says "when all required signatures are complete". This is not the full meaning anymore after #8706. A workflow can finish asSIGNEDwith 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:
This case is important because it defines the behavior we are choosing for
continue.The plan for
workflowCanceledalso 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.