- Shipped
- August 6, 2026 at 7:20 PM UTC
- Author
- Kamo
- Commit
- 593ffe7
verify() compared the caller to assignment.teamMemberId but never to the ENVELOPE RECIPIENT's member — it trusted the recipientUid the delivery fan-out wrote. A mis-bound uid (a delivery bug, a reused envelope, a hand-repaired row) would let one employee's WORM attestation rest on a colleague's signature, on a row whose entity refuses updates. The recipient's own memberId is now carried back by LegalEsignClient and asserted. Absence does NOT refuse: ESigService omits memberId for an external signer and a build predating the field omits it for everyone, so a deploy-order skew must not become "nobody can finish a package". Fail open on unknown, closed on wrong. LegalAssignmentDocumentDto gains recipientStatus, populated by the new LegalMemberRecipientStatus. Nothing in this service writes SIGNED or ACKNOWLEDGED to a document row before finish() does, so the row alone reports every document a member signed yesterday as outstanding; the wizard then re-opens a signing session and EsignPublicService.submit answers 409 "Recipient already signed", permanently. It is a separate component called by the controller, not two lines inside detail(): that method is transactional AND writes, and N outbound calls inside it would hold a write transaction across every timeout — while a non-transactional wrapper on the same bean would be self-invocation and silently lose the annotation. LegalClientIp.isIpLiteral was a character-class check, so "1.2.3.4.5" passed and InetAddress.getByName resolved it as a HOSTNAME — an outbound DNS query, on the request path, for a name the client writes into a header. It now counts groups and ranges. And an over-long sourceIp is recorded as NULL rather than truncated: 45 characters is the longest textual IP there is, so anything longer is not an address, and its first 45 characters would read as a real one forever.