Annual Leave Promotion — Individual Retry and Sign Ticket Error Repair Plan
Status: implemented; local migration and one-row QA data repair pending Date: 2026-08-06 Scope: individual first-notice failure and retry only Approval rule: before changing code or local test data, the executing Agent must obtain Eron's explicit approval. Commit and push require separate approval.
Follow-up blocker: Sign automatic send currently fails with
-117 INVALID_CONTRACT_BODYand leaves orphan drafts. Continue with 2026-08-06-annual-leave-promotion-sign-invalid-body-handoff.md before further manual retry.
1. Problem
An individual annual-leave promotion first notice failed while Hereby Sign returned:
Hereby error (/contract/external/create-and-send-from-template): code=-152Sign defines -152 as NOT_ENOUGH_TICKET. DHub correctly persisted the operation as:
- business status:
not_notified; - processing status:
retryable_failure; - Sign attempt status:
failed; - e-sign status:
error; - original
firstNoticeOperationKeyandexternalRequestKey: preserved.
When the administrator clicks 개별 통지 again, the admin frontend generates a new UUID and submits a different operation key. The API rejects it with:
실패한 안내는 같은 요청으로 재시도해 주세요.This rejection is correct. The UI is wrong: it offers a new-send action for a retryable failed request and does not provide an individual retry action that preserves the original idempotency boundary.
2. Root Cause
| Layer | Current behavior | Defect |
|---|---|---|
| Sign | Checks one common contract ticket before finalEdit; returns -152 when none is available | Expected billing behavior |
| DHub Sign client | Maps unknown Sign codes to a generic Hereby error ... code=-152 | User-facing message is not actionable |
| DHub API | Requires a retry to use the original firstNoticeOperationKey | Correct idempotency protection |
| Admin row action | Looks only at businessStatus; not_notified + retryable_failure is rendered as 개별 통지 | Retryable failure is not represented |
| Admin submit | Always generates annual-leave-promotion:first:{employeeId}:{randomUUID} | A retry is sent as a new request |
| Individual request snapshot | Template ID and duration are stored, but template fingerprint is not stored on ESignRecord | The server cannot fully reconstruct the immutable original request without trusting the browser |
The core problem is not the backend conflict message. A retryable operation must be resumed from server-owned snapshots with the same idempotency key; the browser must not manufacture retry identity.
3. Repair Principles
- Add an explicit server-owned individual retry API that accepts only
promotionId. - The server reconstructs the retry from the original promotion, attempt, and e-sign snapshots.
- Reuse the original
firstNoticeOperationKey/externalRequestKey. - Reuse the same employee, period, template ID, template fingerprint, and response duration.
- Reuse the same
AnnualLeavePromotion,AnnualLeavePromotionSignAttempt, andESignRecord; do not create a second logical attempt for a transport/payment failure before publication. - Never delete the failed record to make the button work.
- Never bypass Sign ticket validation in production or development code.
- If remote success is uncertain or a contract/reference/message already exists, do not retry blindly; move to or preserve
review_required. - Show
-152as a Korean, actionable error while keeping the numeric code in structured logs. - Local QA ticket provisioning is test-data setup, not product logic.
4. In Scope
- Add a dedicated individual first-notice retry schema, SDK method, router mutation, and service method.
- Persist the authoritative Sign template fingerprint for new individual first-notice e-sign records.
- Return a server-derived
canRetryFirstNoticeflag in the promotion list. - Render
재시도for retryable individual failures. - Retry through the dedicated endpoint without reopening editable template/deadline controls.
- Map Sign
-152to an actionable KoreanTRPCError. - Add unit, API/service, frontend, and manual QA coverage.
- Document local Sign ticket setup as a prerequisite for successful QA.
5. Out of Scope
- Bypassing or removing Sign ticket consumption.
- Automatically purchasing or granting production tickets.
- Changing batch retry behavior.
- Changing reminder, expired reissue, or second-designation behavior.
- Reusing a failed request with a different employee, period, template, or duration.
- Deleting audit records, failed attempts, e-sign records, or remote draft contracts.
- Compatibility fallback for old rows that have no trustworthy template fingerprint snapshot.
6. Target State
First notice
-> Sign returns -152
-> DHub stores retryable_failure with original request identity
-> UI shows 재시도
-> Admin confirms retry
-> API loads original server snapshots
-> API calls Sign with the same request key
-> Success: publish one message and move to awaiting_signature
-> Failure: remain retryable_failure with updated error/audit evidenceThe administrator cannot change the template or response duration during a retry. Changing request content is a new business operation, not a retry.
7. Data Model
7.1 Add the missing individual template snapshot
Modify packages/database/src/entities/e-sign-record.entity.ts:
@Column({ type: "varchar", length: 128, nullable: true })
templateFingerprintSnapshot?: string | null;Add migration:
packages/database/src/migrations/1878000000000-AddESignTemplateFingerprintSnapshot.tsMigration behavior:
- add
templateFingerprintSnapshot varchar(128) NULL; - do not fabricate or infer fingerprints for historical records;
- down migration drops only this column.
The column is nullable because e_sign_records also contains non-promotion and historical records. For every new annual-leave promotion first notice, the service must require the authoritative fingerprint returned by listTemplates and save it before the remote create/send call.
7.2 Existing local failed QA row
The current local failure predates this snapshot. Do not add a compatibility fallback that silently uses the current template version.
For local QA only, after the repair is implemented and before retesting:
- grant the Holder a valid local test ticket through approved test-data setup;
- run migration
1878000000000-AddESignTemplateFingerprintSnapshot; - read the exact original fingerprint from Sign's
dhub_external_contract_requestsrow by the preserved request key; - backfill only the matching local QA
e_sign_recordsrow using an explicit, reviewed development-data operation; - retry the existing first notice.
Read-only verification on 2026-08-06 confirmed that the one local retryable failure has a matching Sign request in preparing state, has no remote contract ID, and its original fingerprint still matches the current template. Therefore this exact one-row backfill is safer than deleting the audit trail or creating a new request.
This cleanup must never become an application endpoint or production migration.
8. Backend API
8.1 Core schema
Modify packages/core/src/schemas/annual-leave-promotion.schema.ts:
export const retryFirstNoticeSchema = z.object({
promotionId: uuidSchema,
});Export the corresponding input type through the existing schema export chain.
8.2 Router
Modify packages/api/src/routers/annual-leave-promotion.router.ts and add:
annualLeavePromotion.retryFirstNoticeRules:
- use the same scoped promotion permission as
firstNotice; - load the promotion by
organizationId + promotionId + deletedAt IS NULL; - call
assertInScopewith the server-loaded employee ID; - accept no operation key, employee ID, template ID, fingerprint, period, or duration from the browser.
8.3 Retry service
Add retryFirstNotice beside sendFirstNotice in packages/api/src/services/annual-leave-promotion.service.ts.
It must load, in the same tenant:
AnnualLeavePromotion;- its
currentSignAttemptId; AnnualLeavePromotionSignAttempt;- related
ESignRecord.
Required preconditions:
| Check | Required value |
|---|---|
| Promotion processing status | retryable_failure |
| Promotion business status | not_notified |
| Attempt status | failed |
| E-sign status | error |
| Message publication | no published message / no messageId |
| Request identity | firstNoticeOperationKey === externalRequestKey and both present |
| Contract evidence | no recipientReference, no published deadline, no successful send timestamp |
| Retry snapshots | template ID, fingerprint, response duration, employee, and period all present |
If remote-success evidence is contradictory or uncertain, return a Korean conflict error and transition to review_required where valid. Do not create or send another request.
After validation, call the existing sendFirstNotice using only server-loaded values:
employeeId <- promotion.employeeId
periodKey/start/end <- promotion snapshots
herebyTemplateId <- eSignRecord.herebyTemplateId
templateFingerprint <- eSignRecord.templateFingerprintSnapshot
responseDurationSeconds <- attempt/e-sign snapshot (must agree)
operationKey <- promotion.firstNoticeOperationKey
actorId <- current authenticated actorThe retry must preserve the original target/schedule/template snapshots. The current actor is recorded as the retry executor in a new audit event, but does not replace the original request identity.
8.4 Retry audit event
Append an event before or as part of the claimed retry transition:
first_notice_retry_requestedPayload should contain only non-secret evidence:
- attempt ID;
- e-sign record ID;
- previous error category/code;
- retry actor ID;
- retry timestamp.
Never store API secrets, bearer URLs, or raw credentials.
9. Sign Error Mapping
Modify packages/api/src/services/hereby-contract.service.ts:
case -152:
return new TRPCError({
code: "PRECONDITION_FAILED",
message: "Sign 서명 이용권이 부족해요. 이용권을 충전한 뒤 같은 요청으로 재시도해 주세요.",
});Requirements:
- UI receives Korean 해요체 text;
- structured server logs retain endpoint and remote code
-152; - credentials and request bodies are not logged;
- other unknown codes keep the existing generic handling.
10. Promotion List Contract
Modify the list response in packages/api/src/routers/annual-leave-promotion.router.ts to return:
canRetryFirstNotice: booleanCompute it on the server from the relevant statuses and snapshot availability. Do not make the browser reproduce the complete state machine.
The browser does not need the raw firstNoticeOperationKey to retry. Keeping retry identity server-owned prevents accidental mutation and duplicate contracts.
11. SDK
Modify packages/sdk/src/services/AnnualLeavePromotionService.ts:
async retryFirstNotice(promotionId: string) {
return this.client.trpc.annualLeavePromotion.retryFirstNotice.mutate({ promotionId });
}Do not add optional legacy payload fields or client-generated operation keys.
12. Admin UI
12.1 Row action
Modify apps/admin/src/views/tabs/leave-management.html.
Action precedence must be explicit:
canRetryFirstNotice->재시도;awaiting_signature->재안내;expired_cancelled->다시 발송;applied->완료 내역;- otherwise ->
개별 통지.
The retry check must precede the business-status checks because the failed operation remains not_notified.
12.2 Retry interaction
Modify apps/admin/src/app.ts and add:
retryAnnualLeavePromotion(promotion: Record<string, unknown>): Promise<void>Behavior:
require
promotion.idandcanRetryFirstNotice;show
askConfirmwith:text실패한 통지를 같은 요청으로 다시 시도할까요?disable repeated clicks while the mutation is pending;
call
sdk.annualLeavePromotion.retryFirstNotice({ promotionId });refresh annual-leave balances/promotions;
show the API's actionable Korean error on failure.
Do not reopen the ordinary first-notice modal for retry. That modal permits template/deadline selection and therefore represents a new request, not a retry.
12.3 Existing first-notice submission
Keep UUID generation for genuinely new individual first notices. The dedicated retry action must never call that new-send branch.
13. Local QA Ticket Prerequisite
The currently bound local Sign Holder is the master user of Hereby Demo Corporation and has zero active common-contract tickets. A successful retry is impossible until local test entitlement exists.
Use an approved local seed/setup path to grant enough common tickets for the planned test count. Batch tests require at least one ticket per successful employee send.
Do not:
- bypass
PaymentHttpService.checkTickets; - hardcode free sends for DHub;
- change production plans;
- directly mutate a shared or non-local database.
14. Tests
14.1 Sign client error mapping
Add or update tests for packages/api/src/services/hereby-contract.service.ts:
- remote
-152becomesPRECONDITION_FAILED; - message is the fixed Korean copy;
- unknown errors remain generic;
- credentials are not included in error text.
14.2 Retry service tests
Add focused tests for:
- valid failed request reuses the original operation key;
- valid retry reuses the same promotion, attempt, and e-sign IDs;
- employee/template/period/duration/fingerprint come from server snapshots;
- missing fingerprint denies retry;
- different promotion/e-sign request keys deny retry;
- non-
retryable_failurepromotion denies retry; - non-
failedattempt denies retry; - published message denies retry;
- existing recipient reference or contradictory remote-success evidence denies blind retry;
- cross-organization and out-of-scope access are denied;
- double-click/concurrent retry remains idempotent;
- repeated
-152leaves one retryable workflow and updates error/audit evidence; - successful retry transitions to
awaiting_signatureand publishes exactly one message.
14.3 Frontend tests
Add tests proving:
not_notified + canRetryFirstNoticerenders재시도;- retry has precedence over
개별 통지; - retry calls only
{ promotionId }; - retry never generates a UUID;
- retry does not open the editable first-notice modal;
- retry refreshes the list after success;
- pending state prevents a duplicate click;
- Sign ticket shortage displays the Korean actionable message.
14.4 Migration test
Verify migration up/down and entity metadata for templateFingerprintSnapshot.
15. Manual QA
Failure and retry
- Use a local Holder with zero tickets.
- Send one individual first notice.
- Confirm the UI displays the Korean ticket-shortage message.
- Confirm the row becomes
재시도, not개별 통지. - Confirm one promotion, one attempt, and one e-sign record exist.
- Grant one local common-contract test ticket through the approved setup path.
- Click
재시도and confirm the retry prompt. - Confirm the same operation key and local record IDs are reused.
- Confirm exactly one Sign request is successfully sent.
- Confirm exactly one Messenger workflow message is published.
- Confirm status becomes
1차 안내됨 · 서명 대기. - Confirm the one ticket is consumed once.
Negative cases
- Double-click retry -> one remote request and one message.
- Refresh before retry -> action remains
재시도. - Change the Sign template after failure -> retry fails safely; no new contract.
- Remove organization scope -> retry is denied.
- Retry an awaiting/completed/expired request -> denied with the correct Korean message.
16. Verification Commands
Run targeted tests first, then package builds, then repository verification:
pnpm --filter @hereby/api test
pnpm --filter @hereby/sdk build
pnpm --filter @hereby/admin test
pnpm --filter @hereby/api build
pnpm --filter @hereby/admin build
pnpm lint:check
pnpm buildRun the exact focused Vitest files once their filenames are chosen; do not rely only on broad package scripts.
17. Acceptance Criteria
- [x] Sign
-152is shown as an actionable Korean message. - [x] An individual retryable failure renders
재시도. - [x] The retry API accepts only
promotionIdfrom the browser. - [x] Retry identity and request snapshots are loaded by the server.
- [x] The same operation key, promotion, attempt, and e-sign record are reused.
- [x] Concurrent retry is atomically claimed before the remote call.
- [x] Contradictory/uncertain remote state is not retried blindly.
- [x] New individual requests persist the template fingerprint snapshot.
- [x] Batch retry, reminder, reissue, and second designation are unchanged.
- [ ] Local migration and the verified one-row QA fingerprint backfill are applied.
- [ ] Manual QA confirms one Sign contract and one Messenger message.
- [x] Focused tests, affected package builds, and full repository build pass.
- [ ] Full repository lint passes; unrelated existing employee-app CSS and maintenance-script diagnostics still block it.
18. Files Expected to Change During Implementation
Add
packages/database/src/migrations/1878000000000-AddESignTemplateFingerprintSnapshot.ts- focused API/service/frontend test files as required by existing test organization
Modify
packages/database/src/entities/e-sign-record.entity.tspackages/core/src/schemas/annual-leave-promotion.schema.tspackages/api/src/services/hereby-contract.service.tspackages/api/src/services/annual-leave-promotion.service.tspackages/api/src/routers/annual-leave-promotion.router.tspackages/sdk/src/services/AnnualLeavePromotionService.tsapps/admin/src/app.tsapps/admin/src/views/tabs/leave-management.html
Do not modify Hereby Sign ticket validation for this repair.