Skip to content

feat(rejection): let the workflow continue after a rejection - #8706

Open
maia-andre wants to merge 5 commits into
LibreSign:mainfrom
maia-andre:feat/8405-rejection-continue-behavior
Open

maia-andre wants to merge 5 commits into
LibreSign:mainfrom
maia-andre:feat/8405-rejection-continue-behavior

Conversation

@maia-andre

Copy link
Copy Markdown
Contributor

Part of #8405. This is the second of the three pull requests agreed in the issue: #8423 brought the policy model, this one brings the behavior, and the third will adapt #8398 to rejection_visibility and settings.canSign.

📝 Summary

With rejection_behavior = continue, a rejection no longer stopped the other signers. Nothing after that point knew about rejected signers, though. The file could never finish, the signer who refused was still offered the signature, and in a sequential flow the signers after them waited forever. This PR covers the items that step 2 of the issue lists:

  • continue completes the file. When every signer is SIGNED or REJECTED and at least one of them signed, the file becomes SIGNED. No new FileStatus is added, and the rejected signer keeps its own REJECTED status. The behavior comes from the configuration frozen on the request, so a later policy change cannot complete an existing workflow or reopen it. Observers were already left out of that evaluation.
  • A rejected signer cannot sign later. SignFileService refuses the attempt with its own message. FileListService stops counting rejected signers as pending, and the file list stops offering them the button.
  • settings.canSign is false for rejected signers, for observers, and for a signer whose turn has not come. SignersLoader now applies the rules that SignerValidator enforces at signing time. The request must be ABLE_TO_SIGN and belong to a signing participant. In an ordered flow, no signer with a lower order may still be pending. Before this change, a DRAFT signer waiting for their turn was offered a signature that the endpoint then refused.
  • A rejection releases the next order in a sequential flow. SignatureRejectionService calls SequentialSigningService::releaseNextOrder() inside the rejection transaction, for every document the signer rejected, so an envelope releases the next order on each of its documents. isOrderFullyCompleted() treats REJECTED as done, the same way hasPendingLowerOrderSigners() already did. If the release fails, the rejection rolls back.

cancel is unchanged: the rejection still closes the file, and none of the code above runs.

Composition and reading order

+582/−11 in 11 files. About 117 of those lines are production code and 465 are tests (unit tests and one Behat scenario). There is no generated code: the response shapes do not change, and composer openapi produces no diff.

The commits can be read one by one:

  1. feat(rejection): complete the workflow when a rejection does not cancel it: SignFileService (start here)
  2. fix(rejection): keep a rejected signer out of the signing flow: SignFileService, SignersLoader, FileListService
  3. test(rejection): cover the continue behavior end to end: a new scenario in reject.feature
  4. fix(sign): offer the signature only when it is the signer's turn: SignersLoader
  5. fix(rejection): release the next signing order when the workflow continues: SignatureRejectionService, SequentialSigningService

❓ One point for you

When every signer rejects, no signature exists, and the file is left as it was. The issue requires at least one signature before a file can complete, and it rules out a new status for this case, so this PR does not decide it. Should the file stay as it is, should it be canceled like with cancel, or should this be tracked separately?

🧪 How to test

The Behat scenario in tests/integration/features/sign/reject.feature covers the parallel case end to end:

cd tests/integration
vendor/bin/behat features/sign/reject.feature -v

Two signers, with rejection_enabled = true and rejection_behavior = continue. The first signer rejects, and the response carries workflowCanceled: false. That signer is no longer offered the signature, and a direct signing request from them is refused. The second signer signs, and the file reaches SIGNED while the signers keep REJECTED and SIGNED.

Unit tests cover the sequential flow: turn handling in canSign, releasing the next order after a rejection, envelopes, and rollback. To check it by hand:

  1. Allow rejection with continue (the rejection_enabled and rejection_behavior policies).
  2. Request a signature from three signers with signing orders 1, 2 and 3.
  3. As signer 1, reject. Signer 2 now receives the request, and settings.canSign becomes true for them.
  4. Signer 3 still sees settings.canSign = false until signer 2 signs. When signer 3 signs, the file becomes SIGNED.

⚙️ API / Back‑end changes

  • File status: with continue, the file becomes SIGNED once every signer has signed or rejected and at least one signature exists
  • Signing endpoint: refuses a signer who has already rejected
  • settings.canSign: false for rejected signers, observers, and signers whose turn has not come
  • Sequential flow: a rejection releases the next order
  • Unit and/or integration tests added – required for backend changes
  • Capabilities updated (if applicable): not applicable
  • Documentation updated (if applicable): the rejection behavior will be documented once the three PRs land
  • API documentation updated with the command composer openapi if necessary: ran it, no diff

✅ Checklist

  • I have read and followed the contribution guide.
  • Focused PHPUnit runs: SignFileServiceTest, FileListServiceTest, SignersLoaderTest, SignatureRejection/, SequentialSigningServiceTest
  • php-cs-fixer and psalm are clean on the changed files

🤖 AI (if applicable)

  • The content of this PR was partially or fully generated using AI

…el it

With `rejection_behavior = continue` a rejected signer no longer stops the
others, but the file status was still derived from signatures alone: a
signer who refused keeps `signed = null` forever, so the file stayed
`PARTIAL_SIGNED` even after everybody had acted and the workflow had
nothing left to wait for.

Complete the file when no one is expected to act any more and at least one
signature was actually applied, which is what LibreSign#8405 asks for: every signer
is either `SIGNED` or `REJECTED`, and at least one of them signed. The
file-level status becomes `SIGNED`, without a new `FileStatus`, because it
describes the configured workflow having finished; the rejected signer keeps
its own `REJECTED` status and is never converted.

The behavior is read from the configuration frozen on the request, so a
later policy change cannot complete or reopen an existing workflow, and
`cancel` is unaffected: a rejection closes the file there and nothing is
evaluated afterwards.

When every signer rejects, no signature exists and the file is left as it
was. LibreSign#8405 requires at least one signature to complete and forbids a new
status for this case, so nothing else is decided here.

Ref: LibreSign#8405

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: André Maia <andrefnkmm@gmail.com>
A signer who refused keeps `signed = null`, and everything that decides who
may still act read exactly that, so with `rejection_behavior = continue` the
rejected signer was still treated as pending: the signing endpoint accepted
their signature, the file list and the signer list still offered them the
button, and in a sequential flow they kept the turn nobody would ever take.

Refuse the signature itself, and stop counting a rejected signer as someone
the workflow is waiting for:

* `SignFileService::getSignRequestToSign()` rejects the attempt with its own
  message, next to the one for a document already signed;
* `SignersLoader` no longer sets `canSign` for the viewer's own rejected
  entry;
* `FileListService` leaves rejected signers out of the pending set, which
  also frees the sequential flow — `SequentialSigningService` already
  ignored them, so the summary now agrees with the gate that enforces it.

With `cancel` none of this is reachable, because the rejection closes the
file and `fileCanBeSigned()` refuses everyone.

Ref: LibreSign#8405

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: André Maia <andrefnkmm@gmail.com>
`reject.feature` only exercised rejections that close the workflow. Add the
other half: with `rejection_behavior = continue`, one of two signers refuses
and the rejection reports `workflowCanceled: false`, the signer who refused
is no longer offered the signature and is refused if they ask for it anyway,
the other signer signs, and the file reaches `SIGNED` while the signers keep
their own results, `REJECTED` and `SIGNED`.

The scenario installs the signing tools the way the TSA feature does,
because a scenario that reaches a real signature needs them and the
per-scenario reset clears their configuration.

Ref: LibreSign#8405

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: André Maia <andrefnkmm@gmail.com>
`settings.canSign` in the file response was set for the viewer's own entry
whenever it was not signed, not rejected and not an observer. In a
sequential flow that included a signer still waiting for the previous
order, whose request stays `DRAFT` until `SequentialSigningService`
activates it, so the response offered a signature the signing endpoint
refuses with "You are not allowed to sign this document yet", and
`MessagesLoader` told them "You need to sign this document".

Apply the rules `SignerValidator` enforces when signing: the request must
be `ABLE_TO_SIGN`, belong to a signing participant, and, in an ordered
flow, no signer with a lower order may still be pending. Signed and
rejected signers do not block the turn, which matches
`SequentialSigningService::hasPendingLowerOrderSigners()` and lets the
workflow continue after a rejection. The check uses the signers already
loaded for the response, so it adds no query.

The approver of an identification document is unaffected: their
`canSign` comes from `SettingsLoader`, and this code only ever sets the
flag.

The test for the rejected signer becomes a matrix that also covers the
parallel, draft, observer and sequential cases.

Ref: LibreSign#8405

Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: André Maia <andrefnkmm@gmail.com>
…inues

In a sequential flow, a rejection that does not cancel the workflow
ended the signer's turn without activating the next order: the signers
after them stayed `DRAFT` forever, and the document could never reach
`SIGNED`.

`SignatureRejectionService` now calls
`SequentialSigningService::releaseNextOrder()` inside the rejection
transaction for every document the signer rejected, so an envelope
releases the next order on each of its documents. A failure while
releasing rolls the rejection back.

`isOrderFullyCompleted()` also treats `REJECTED` as done, matching
`hasPendingLowerOrderSigners()`; otherwise the release would still wait
for the rejected signer.

Ref: LibreSign#8405

Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: André Maia <andrefnkmm@gmail.com>
@maia-andre
maia-andre requested a review from a team as a code owner September 24, 2026 23:17

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant