Annual Leave Promotion — Sign -117 Invalid Body Repair Handoff
Status: implementation complete; local orphan recovery pending explicit approval Date: 2026-08-06 Projects:
hereby-dhub+hereby-signExecution rule: the implementing Agent must obtain Eron's explicit approval before changing code or local data. Commit and push require separate approval.
Follow-up found after the successful
-117repair: the firstsigner_waitingwebhook can race DHub's contract-ID binding and return404. Implement the separate webhook record-binding race plan before considering the integration complete.
1. Goal
Fix the automatic annual-leave promotion Sign flow so that:
- Hereby Sign no longer returns
-117 INVALID_CONTRACT_BODYfor a valid P1 employee-only template; - retry reuses the same Sign draft/request instead of creating another draft;
- DHub shows an actionable Korean message when Sign genuinely rejects an invalid template body;
- the two current local orphan drafts are recovered or cancelled safely;
- one retry produces exactly one sent contract and one Messenger workflow message.
Do not bypass Sign ticket validation and do not delete audit records to make the flow pass.
2. Current Local State
DHub
Request key:
textannual-leave-promotion:first:cbaa9703-0a93-4f95-8370-61337d2086a4:884fa537-04e4-4d0e-a10b-b50a9068e484Promotion processing status:
retryable_failurePromotion business status:
not_notifiedSign attempt status:
failedE-sign status:
errorThe authoritative template fingerprint has already been backfilled from Sign.
DHub currently considers the request eligible for retry.
Sign
Read-only verification found:
dhub_external_contract_requests.status = 'preparing';dhub_external_contract_requests.contract_id IS NULL;- two API-created contracts remain in draft status
-2; - neither draft was sent;
- the request's original template fingerprint still matches current template
5.
Do not click retry again before the Sign fix. The current implementation would create another orphan draft.
3. Error Definition
Hereby Sign defines:
INVALID_CONTRACT_BODY: -117Source:
hereby-sign/apps/contract-server/src/constants/response.tsThe exception is raised by ContractService.validBody() when the body is absent, malformed, or contains a component without assigned.
4. Confirmed Root Cause
Relevant Sign flow:
externalCreateAndSendFromTemplate
-> externalCreateFromTemplate
-> create draft contract
-> save components/signers to contract.editing_data
-> contractQuery.getContract
-> finalEdit(contract.body, contract.users, ...)
-> validBody
-> -117externalCreateFromTemplate() saves the generated data into:
contract.editing_data.body
contract.editing_data.usersBefore finalEdit(), the normalized values do not yet exist in:
contract.body
contract_userThe interactive editor path calls checkPrivilege(), which hydrates draft data by:
- mapping
editingData.userstocontract.users; - converting
editingData.bodyfrom{ fileId: { componentId: component } }to{ fileId: component[] }.
The automatic externalCreateAndSendFromTemplate() path does not call that hydration logic. It passes an empty/undefined contract.body and empty contract.users to finalEdit(), so validBody() returns -117.
This is a Sign backend flow defect, not evidence that the uploaded PDF coordinate JSON is inherently invalid.
5. Secondary Idempotency Defect
dhub_external_contract_requests is inserted before draft creation, but contract_id is currently written only after finalEdit() succeeds.
Therefore, when anything fails between draft creation and request finalization:
request row = preparing + contract_id NULL
draft contract = already createdThe next retry sees no request contract_id and creates another draft. This caused the current two orphan drafts and must be fixed together with -117.
6. Required Sign Design
6.1 Keep one idempotency boundary
The request key remains the only external idempotency key:
dhub_external_contract_requests.request_keyAll create/resume/finalize logic for the same request key must execute under a per-request database lock. For MySQL, use a dedicated connection with a bounded named lock:
GET_LOCK('dhub-external:' + sha256(requestKey), timeout)
RELEASE_LOCK(...)Requirements:
- hash/limit the lock name to MySQL's supported length;
- always release in
finallyon the same connection; - after acquiring the lock, re-read the request row;
- return a Korean/service error if the lock cannot be acquired within the bounded timeout;
- do not hold one global lock across different request keys.
An equivalent transaction-safe implementation is acceptable only if it prevents two app instances from creating two drafts for the same request key.
6.2 Request state handling
After acquiring the request lock:
The presence of contract_id alone must never be treated as proof that the contract was sent. A row with status = preparing and a non-null contract_id is a resumable draft state, and its sent_at and recipient_reference may still be null.
status = sent(or the final sent state used by the schema) and complete response fields exist:- return the stored result;
- do not call
finalEdit()again.
status = preparingandcontract_idexists:- load and resume that exact draft;
- do not call
newContract(). - do not return
status = sentor serialize nullablesent_atvalues as dates.
status = preparingandcontract_id IS NULL:- create one draft;
- immediately bind its ID to the request row before
finalEdit(); - subsequent retries must resume it.
- Request says
preparing, but the linked contract is already sent:- reconcile the request row from authoritative contract/access data;
- do not send or consume a ticket twice.
- Linked contract has an unsafe/contradictory state:
- return a review-required conflict;
- do not create another draft.
The current early return for any non-null contract_id must become status-aware. A preparing draft is not a successfully sent contract.
6.3 Hydrate the draft before finalEdit()
Do not call the entire interactive checkPrivilege() flow merely to obtain draft data; it mixes access control and view preparation.
Extract or reuse a focused normalization helper that:
body <- normalize editingData.body to { fileId: component[] }
users <- editingData.users
componentGroups <- editingData.componentGroups or template preset groupsBefore calling finalEdit(), validate:
- body is a non-empty object;
- every document entry is an array;
- every component has
assigned; - signer users are present;
- component assignments match the signer identities;
- only supported P1 assignment type
1is used for this flow.
externalCreateFromTemplate() must include componentGroups in editingData; automatic send must pass those groups to finalEdit().
6.4 Failure behavior
If validation or finalEdit() fails:
- keep the request linked to the same draft;
- keep request status
preparingunless the state is contradictory; - return the remote numeric code to structured logs;
- never create a replacement draft automatically;
- allow a later same-key retry to resume only when safe.
If finalization succeeds but updating the request row fails, the next same-key request must reconcile the already-sent contract instead of calling finalEdit() again.
The finalEdit() and ticket-use calls are not part of one database transaction. Therefore the implementation must treat a linked contract that has already left draft state as finalized, reconcile its response fields, and skip both another finalEdit() and another ticket-use call. Ticket validation remains enabled before the first finalization, while a retry must never charge a second ticket for the same request key. A ticket-use failure after finalization must surface as a resumable/reviewable state rather than triggering a blind resend.
7. Expected Sign Files
Primary files:
/Users/joshua/Project/FIG/hereby/hereby-sign/apps/contract-server/src/app/contract/contract.service.ts
/Users/joshua/Project/FIG/hereby/hereby-sign/apps/contract-server/src/utils/mysqlManager.ts
/Users/joshua/Project/FIG/hereby/hereby-sign/apps/contract-server/src/app/contract/__tests__/contract.external.service.spec.tsPossible migration only if the chosen locking/state design needs additional claim/recovery columns:
/Users/joshua/Project/FIG/hereby/hereby-sign/apps/contract-server/src/database/migrations/The Sign worktree already contains uncommitted changes in these areas. Preserve them and inspect the diff before editing. Do not reset or overwrite existing work.
8. Required DHub Change
Add -117 mapping in:
packages/api/src/services/hereby-contract.service.tsRecommended user-facing error:
Sign 서식의 서명 필드 구성이 올바르지 않아요. 서식을 확인한 뒤 같은 요청으로 재시도해 주세요.Suggested tRPC code: PRECONDITION_FAILED.
Keep endpoint and numeric code -117 in structured server logs. Never expose credentials or raw request bodies.
Add focused coverage in:
packages/api/src/services/hereby-contract.service.test.tsDo not change DHub's original request key or create a new first notice for this recovery.
9. Local Data Recovery
Perform only after the Sign code and tests pass.
9.1 Identify exact drafts
Under a read-only query, locate candidates using all of:
- API user equals the request row's
user_id; - contract status is draft
-2; - API contract record timestamp is on/after the request
created_at; - contract name matches the annual-leave promotion request;
editing_datacontains the expected template/body/signers;- no sent/completed/cancelled evidence exists.
The expected candidate count is exactly 2. Stop if it differs.
9.2 Recover one, retire one
Preferred local recovery:
- choose the newest fully valid matching draft;
- bind it to
dhub_external_contract_requests.contract_idonly while the request is stillpreparingandcontract_id IS NULL; - keep the request status
preparingso the fixed endpoint resumes it; - retire the older orphan through the existing Sign draft cancel/destroy domain path;
- preserve audit evidence; do not hard-delete unless the Sign domain explicitly defines local test draft deletion as safe.
All data changes must be transactional and assert an exact affected-row count. Roll back if any precondition or count differs.
Do not touch other drafts, users, tickets, contracts, or request rows.
10. Sign Tests
Add focused tests proving:
- a valid P1 template hydrates
editingData.bodybeforefinalEdit(); editingData.usersbecomes the signer list used byfinalEdit();- component groups are preserved;
- valid automatic send does not return
-117; - a genuinely unassigned component still returns
-117; - first call binds the draft ID before finalization;
- retry of
preparing + contract_idreuses that contract ID; - retry never calls
newContract()a second time; - concurrent same-key calls produce one draft and one send;
- a failure after draft creation leaves one resumable linked draft;
- a failure after finalization is reconciled without another
finalEdit(); status = sentreturns the stored response;- native Sign notifications remain disabled for DHub workflow sends;
- ticket validation remains enabled and one successful send consumes at most one ticket.
11. DHub Tests
Add or extend tests proving:
- Sign
-117becomesPRECONDITION_FAILED; - the UI receives the fixed Korean message;
- numeric code and endpoint remain in structured logs;
- retry continues to use only the original server-owned request key;
- a repeated failure does not create a new DHub promotion, attempt, or e-sign record.
12. Verification
Hereby Sign
Use the repository's existing package commands after inspecting its package manager configuration:
pnpm --filter hereby-contract-server test -- contract.external.service.spec.ts
pnpm --filter hereby-contract-server buildRun the relevant Sign lint command if available. Do not auto-fix unrelated dirty files.
DHub
pnpm --filter @hereby/api exec vitest run src/services/hereby-contract.service.test.ts
pnpm --filter @hereby/api build
pnpm --filter @hereby/admin build
pnpm buildpnpm lint:check currently has unrelated employee-app CSS and maintenance-script diagnostics. The implementing Agent must still run it and report the existing baseline separately from new diagnostics.
13. Manual QA
After code deployment/restart and approved local recovery:
- refresh DHub attendance > annual leave status;
- confirm the failed employee shows
재시도; - click
재시도once and confirm; - confirm Sign resumes the bound draft rather than creating another;
- confirm one contract reaches signer-waiting/sent state;
- confirm the request row becomes
sentwith one contract ID and recipient reference; - confirm DHub moves to
awaiting_signature; - confirm exactly one Messenger workflow message is published;
- confirm the employee can open the signing URL through the workflow action;
- confirm no native duplicate Sign notification is emitted;
- confirm only the expected ticket usage occurs;
- confirm the orphan draft count becomes zero after the older draft is retired and the recovered draft is sent.
14. Acceptance Criteria
- [ ] Valid P1 automatic send no longer returns
-117. - [ ] Sign hydrates body, users, and component groups from server-owned draft data.
- [ ] Same request key owns at most one resumable draft.
- [ ] Retry never creates a second draft for the same request.
- [ ] Sent-but-not-finalized request state is reconciled without resending.
- [ ] A linked draft is never returned as
sent, and nullable draft timestamps are never serialized as valid dates. - [ ] A retry after finalization never calls
finalEdit()or consumes another ticket. - [ ] DHub maps genuine
-117to KoreanPRECONDITION_FAILED. - [ ] Two current local orphan drafts are handled with exact, reviewed conditions.
- [ ] Original request key and DHub workflow records are preserved.
- [ ] Ticket validation remains enabled.
- [ ] One Sign contract and one Messenger message result from the successful retry.
- [ ] Focused tests and both affected builds pass.
- [ ] No unrelated dirty-worktree changes are overwritten.
15. Explicit Non-Goals
- Do not bypass
checkTickets. - Do not issue a new DHub operation key.
- Do not delete the failed DHub audit trail.
- Do not treat every draft as safe to resume.
- Do not hard-delete shared or production contracts.
- Do not push either repository without Eron's separate approval.