Skip to content

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-sign Execution 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 -117 repair: the first signer_waiting webhook can race DHub's contract-ID binding and return 404. 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_BODY for 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:

    text
    annual-leave-promotion:first:cbaa9703-0a93-4f95-8370-61337d2086a4:884fa537-04e4-4d0e-a10b-b50a9068e484
  • Promotion processing status: retryable_failure

  • Promotion business status: not_notified

  • Sign attempt status: failed

  • E-sign status: error

  • The 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:

ts
INVALID_CONTRACT_BODY: -117

Source:

text
hereby-sign/apps/contract-server/src/constants/response.ts

The 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:

text
externalCreateAndSendFromTemplate
  -> externalCreateFromTemplate
     -> create draft contract
     -> save components/signers to contract.editing_data
  -> contractQuery.getContract
  -> finalEdit(contract.body, contract.users, ...)
  -> validBody
  -> -117

externalCreateFromTemplate() saves the generated data into:

text
contract.editing_data.body
contract.editing_data.users

Before finalEdit(), the normalized values do not yet exist in:

text
contract.body
contract_user

The interactive editor path calls checkPrivilege(), which hydrates draft data by:

  • mapping editingData.users to contract.users;
  • converting editingData.body from { 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:

text
request row = preparing + contract_id NULL
draft contract = already created

The 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:

text
dhub_external_contract_requests.request_key

All 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:

text
GET_LOCK('dhub-external:' + sha256(requestKey), timeout)
RELEASE_LOCK(...)

Requirements:

  • hash/limit the lock name to MySQL's supported length;
  • always release in finally on 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.

  1. 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.
  2. status = preparing and contract_id exists:
    • load and resume that exact draft;
    • do not call newContract().
    • do not return status = sent or serialize nullable sent_at values as dates.
  3. status = preparing and contract_id IS NULL:
    • create one draft;
    • immediately bind its ID to the request row before finalEdit();
    • subsequent retries must resume it.
  4. 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.
  5. 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:

text
body            <- normalize editingData.body to { fileId: component[] }
users           <- editingData.users
componentGroups <- editingData.componentGroups or template preset groups

Before 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 1 is 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 preparing unless 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:

text
/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.ts

Possible migration only if the chosen locking/state design needs additional claim/recovery columns:

text
/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:

text
packages/api/src/services/hereby-contract.service.ts

Recommended user-facing error:

text
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:

text
packages/api/src/services/hereby-contract.service.test.ts

Do 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_data contains 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:

  1. choose the newest fully valid matching draft;
  2. bind it to dhub_external_contract_requests.contract_id only while the request is still preparing and contract_id IS NULL;
  3. keep the request status preparing so the fixed endpoint resumes it;
  4. retire the older orphan through the existing Sign draft cancel/destroy domain path;
  5. 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:

  1. a valid P1 template hydrates editingData.body before finalEdit();
  2. editingData.users becomes the signer list used by finalEdit();
  3. component groups are preserved;
  4. valid automatic send does not return -117;
  5. a genuinely unassigned component still returns -117;
  6. first call binds the draft ID before finalization;
  7. retry of preparing + contract_id reuses that contract ID;
  8. retry never calls newContract() a second time;
  9. concurrent same-key calls produce one draft and one send;
  10. a failure after draft creation leaves one resumable linked draft;
  11. a failure after finalization is reconciled without another finalEdit();
  12. status = sent returns the stored response;
  13. native Sign notifications remain disabled for DHub workflow sends;
  14. ticket validation remains enabled and one successful send consumes at most one ticket.

11. DHub Tests ​

Add or extend tests proving:

  1. Sign -117 becomes PRECONDITION_FAILED;
  2. the UI receives the fixed Korean message;
  3. numeric code and endpoint remain in structured logs;
  4. retry continues to use only the original server-owned request key;
  5. 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:

bash
pnpm --filter hereby-contract-server test -- contract.external.service.spec.ts
pnpm --filter hereby-contract-server build

Run the relevant Sign lint command if available. Do not auto-fix unrelated dirty files.

DHub ​

bash
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 build

pnpm 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:

  1. refresh DHub attendance > annual leave status;
  2. confirm the failed employee shows 재시도;
  3. click 재시도 once and confirm;
  4. confirm Sign resumes the bound draft rather than creating another;
  5. confirm one contract reaches signer-waiting/sent state;
  6. confirm the request row becomes sent with one contract ID and recipient reference;
  7. confirm DHub moves to awaiting_signature;
  8. confirm exactly one Messenger workflow message is published;
  9. confirm the employee can open the signing URL through the workflow action;
  10. confirm no native duplicate Sign notification is emitted;
  11. confirm only the expected ticket usage occurs;
  12. 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 -117 to Korean PRECONDITION_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.