Annual Leave Promotion — Sign Completion Reliability Repair Plan
Status: Patch 6 reliability code was implemented in the local DHub/Sign working trees on 2026-08-07 and Sign migration
1787000000000-DHubCompletionReliabilityexecuted successfully. On 2026-08-10, Eron corrected the product scope to signature-only consent. Atomic completion, idempotent retry, durable webhook Outbox, andnotificationPolicy=noneremain required; the employee plan-policy implementation is superseded and must be removed by Patch 7. Database-backed proof and a fresh E2E remain pending. No contract81data repair is authorized.Safety rule: Contract
81is terminal evidence. Do not reopen, delete, cancel, replay, fabricate a completion webhook for, or mark its DHub promotionapplied. Do not reuse its request key. The next E2E must use a fresh designated QA employee/period after this patch passes automated verification.
Supersession rule (2026-08-10): Employee-entered annual-leave plan fields, plan formatting, plan-policy snapshots, Sign-side plan validation, and DHub employee-plan persistence in this document are historical implementation records, not current requirements. Do not execute or retain those parts. Company-authored second-designation schedules remain in scope and are not affected.
1. Goal
Repair the release-blocking completion boundary exposed by contract 81:
- the first employee completion request committed the Sign contract but returned an unknown error;
- the repeated request reported that the contract was already completed;
- Sign did not durably deliver a completion webhook to DHub;
- the then-current implementation incorrectly treated empty annual-leave plan rows as invalid;
- DHub reconciliation moved the case to
review_requiredunder that now-superseded rule.
Under the corrected scope, one valid employee signature must return success once, commit Sign terminal truth and one durable webhook event atomically, retry delivery independently, and apply the DHub promotion without requiring or storing an employee leave plan.
2. Confirmed QA Evidence
Employee-visible evidence
- The workflow title
연차 사용촉진 안내, sender snapshot, body, deadline, and서명하기CTA rendered correctly in Messenger. - The CTA issued a signer-bound session and opened
/contract/sign/81. - On the first final-completion click, the UI showed an unknown error.
- On the second click, the UI showed
이미 체결된 계약입니다..
Sign database evidence
- Contract
81is terminalstatus = 3withcompleted_at = 2026-08-07 05:22:38. - Its external request remains
published, is not cancelled, and retains the2026-08-16deadline. - The completion and input-completion contract/notification log rows were written.
- Two signer-session rows exist across two different days; the current QA click created one new ten-minute session. This is not evidence of a duplicate session within the current click.
DHub database evidence
- No completion event for contract
81exists insign_webhook_inbox. - Reconciliation observed the remote terminal contract and fetched its result.
- All
annual_leave.plan.{1..20}.dateand.minutesvalues were empty. - DHub recorded
sign_completed_review_requiredwithSign 결과에서 연차 사용계획을 확인할 수 없어요.. - The promotion is
awaiting_signature / review_required; the attempt isreview_required;ESignRecordstill reflects the last accepted waiting state.
QA judgment
- Status:
fail. - Severity:
P1, release blocker. - Classification after the 2026-08-10 scope correction: the atomicity/webhook defects remain release-blocking; empty employee-plan data is expected, and DHub's
review_requiredoutcome came from obsolete validation that Patch 7 must remove.
3. Root Causes
3.1 Terminal state is committed before fallible side effects
The Sign completion paths update contract.status = COMPLETE before awaiting or invoking later work such as completion notifications, RabbitMQ publication, and sendNotiCallback().
Relevant paths include:
apps/contract-server/src/app/contract/contract.service.ts
completeComponent()
nonMemberCompleteComponent()
completePersonComponent()
authentication-driven completion paths
apps/contract-server/src/database/query/contract.query.ts
apps/contract-server/src/app/notification/notification.service.tsContract 81 proves that a later failure can escape to the employee after terminal truth already committed. Retrying the same action then reaches ALREADY_COMPLETE_CONTRACT, which is technically accurate but wrong for an uncertain retry by the same authorized signer.
3.2 The modern DHub webhook is volatile
sendNotiCallback() builds and posts the modern DHub event in process memory. It has no Sign-side Outbox row, durable claim, retry schedule, or delivery audit. Completion paths do not await it consistently, and a preceding exception prevents it from running at all.
This violates the existing project contract that Sign completion webhook delivery is durable and retryable.
3.3 notificationPolicy=none does not protect the completion path
The DHub S1 request requires notificationPolicy = none, but the request row does not persist an explicit notification-policy snapshot for later completion behavior. Completion still enters shared native completion-notification code. DHub workflow contracts must never depend on Sign email/Kakao/SMS delivery and those side effects must not control the employee completion response.
3.4 Historical completion-policy diagnosis — superseded
This diagnosis explains the code added on 2026-08-07. The 2026-08-10 product correction makes that policy unnecessary; Patch 7 removes it.
The template contains annual-leave plan labels, but Sign only snapshots the template fingerprint and visible prefill values. It does not store machine-readable rules for:
- at least one plan row;
- complete date/minutes pairs;
- ISO date parsing and promotion-period bounds;
- unique dates;
- positive 30-minute increments;
- total minutes not exceeding the frozen remaining balance.
DHub validates these rules only after Sign has already become terminal. That is too late to let the employee correct the form.
4. Frozen Patch 6 Decisions
- Sign owns its local completion atomicity. For a DHub-managed contract,
contract COMPLETE, external-requestcompleted, immutable completion timestamp, and one webhook Outbox row commit in one MySQL transaction. - Remote delivery is never part of the employee response transaction. After the local commit, the completion API returns success. Email, Kakao, SMS, PDF, MQ, and webhook delivery cannot turn that committed success into an unknown error.
- Modern DHub webhooks use a transactional Outbox.
sendNotiCallback()is not the delivery boundary for DHub-managed completion events. - Uncertain retries are idempotent. After authenticating the same signer/session and contract, a repeated completion request for the already-committed result returns success with
alreadyCompleted: true; unrelated mutation endpoints keep their existing completed-contract rejection. notificationPolicy=nonecovers the whole contract lifecycle. Sign-native invitation, reminder, completion email, Kakao, and SMS are skipped for DHub workflow contracts. Messenger remains the only employee-notification channel.- Superseded by Patch 7. Sign must not require or validate an employee leave-plan policy before terminal transition.
- DHub remains the business source of truth. It validates signer/completion evidence when processing the completed result, but does not validate or persist an employee leave plan.
- No synchronous DHub pre-commit callback. Sign must not depend on DHub availability to complete locally; the frozen policy contains the data required for deterministic local validation.
- Contract
81stays historical reliability evidence. It is not silently applied, reset, or reused. Its empty plan is valid under the corrected scope, but its first-click response and missing completion event still require a fresh E2E.
5. Target Architecture
5.1 Historical versioned S1 completion policy — superseded
This subsection records the implementation that Patch 7 removes. It is not a target architecture and must not be implemented or retained.
Extend the DHub-to-Sign create/send request with a required policy for annual-leave promotion contracts:
completionPolicy: {
version: 1;
periodStart: string; // YYYY-MM-DD
periodEnd: string; // YYYY-MM-DD
maxTotalMinutes: number; // computed by DHub from frozen leave-unit rules
stepMinutes: 30;
minimumRows: 1;
rows: Array<{
dateLabel: string;
minutesLabel: string;
}>;
}Requirements:
- DHub computes and sends the snapshot from the same period/balance truth used for the promotion.
- Sign validates the DTO, template ownership/fingerprint, exact label presence, employee assignment, supported component types, and policy version before sending.
- Sign stores the exact policy JSON and
notification_policy = 'none'ondhub_external_contract_requests. - Same-request retries must compare the immutable policy snapshot; a changed policy for the same request key is a conflict.
- Do not parse the visible Korean
remaining_daysstring to recover machine values.
5.2 Historical Sign completion-policy validator — superseded
This subsection records obsolete validation. Patch 7 removes the validator and its employee-plan messages/tests.
Before any DHub-managed contract becomes terminal, reload its persisted body and validate:
- at least
minimumRowscontains both date and minutes; - a row with only one side populated is invalid;
- dates are valid ISO dates, inside
[periodStart, periodEnd], and unique; - minutes are integers, positive, and divisible by
stepMinutes; - the total is
<= maxTotalMinutes; - only the expected employee signer supplied the editable plan/signature fields;
- required employee signature evidence exists.
Return typed Korean, 해요체 validation messages without committing terminal status, for example:
연차 사용계획을 한 건 이상 입력해 주세요.사용 예정일과 사용 시간을 함께 입력해 주세요.사용 예정일이 연차 사용기간에 포함되지 않아요.사용 시간 합계가 잔여 연차를 초과해요.
The employee must remain able to correct the contract until its deadline. Weekend/holiday checks remain non-blocking per D7.
5.3 Atomic DHub-managed completion
Create one shared completion primitive used by every member/non-member/person-auth route that can complete a DHub-managed contract:
- authenticate the signer/session and confirm recipient ownership before idempotent handling;
- start a MySQL transaction;
- lock
contractand itsdhub_external_contract_requestsrow; - reload the final persisted body inside the transaction;
- validate required employee signature evidence; do not validate employee leave-plan fields or policy;
- if already complete with the same request and Outbox event, return the committed result idempotently;
- otherwise set
contract.status = COMPLETEand onecompleted_at; - set the external request to
completedwith the same terminal timestamp; - insert exactly one immutable completion Outbox event;
- commit, then return success to the employee.
If Outbox insertion fails, the transaction rolls back the terminal transition. Concurrent double submission must produce one terminal transition and one Outbox row.
After commit:
- skip all native completion notifications when the request snapshot is
none; - run any required PDF/MQ work as isolated best-effort or its own durable job;
- log post-commit failures structurally without changing the API success response.
5.4 Sign webhook Outbox
Add a Sign migration for dhub_external_webhook_outbox with at least:
id / event_id
request_key
external_organization_id
contract_id
event_type
status_version
occurred_at
payload JSON
status (pending | processing | retry_wait | delivered | review_required)
attempt_count
available_at
delivered_at
last_error
created_at / updated_atConstraints and delivery rules:
- unique
event_id; - unique
(request_key, event_type, status_version); - index
(status, available_at); - snapshot the payload in the terminal transaction;
- never store plaintext webhook secrets or a reusable signature;
- claim with row locking/
SKIP LOCKEDor an equivalent single-owner CAS; - compute a fresh timestamped HMAC for each delivery attempt over the immutable payload;
- treat only HTTP 2xx as delivered;
- retry network/5xx/429 with bounded exponential backoff and jitter;
- retain 4xx diagnostics and route exhausted/contradictory rows to review;
- resend the same
eventId,occurredAt, payload, and status version; - expose structured metrics for pending age, attempts, delivery latency, and review-required rows.
The worker starts with the contract-server worker/consumer lifecycle and shuts down cleanly.
5.5 DHub completion handling
Keep the existing timestamped-HMAC receiver and durable Inbox boundary. Extend tests and observability rather than adding a second receiver.
For a valid completed result:
- one webhook event inserts one Inbox row and receives HTTP 200;
- the Inbox worker fetches S3 result and validates signer/completion evidence without reading or writing employee plan items;
ESignRecord, current attempt, promotion, and timeline become completed/applied once;- duplicate Outbox delivery and reconciliation are no-ops;
- no leave request, balance deduction, grant, email, or SMS is created.
For contract 81, preserve the historical evidence without fabricating events or mutating terminal data. Its existing DHub review_required state was produced by obsolete validation and is not the expected outcome for fresh signature-only contracts.
6. Files and Work Slices
Slice A — DHub S1 policy snapshot
Likely DHub files:
packages/core/src/schemas/annual-leave-promotion.schema.ts
packages/api/src/services/hereby-contract.service.ts
packages/api/src/services/annual-leave-promotion.service.ts
packages/api/src/services/hereby-contract.service.test.ts
packages/api/src/services/annual-leave-promotion.service.test.ts- Define one shared DHub schema/type for the completion policy.
- Compute
maxTotalMinutesusing the existing organization/employee leave-unit conversion; do not hardcode a workday length. - Send the immutable policy in S1 and assert same-key retries reuse it exactly.
Slice B — Sign schema, migration, and request validation
Likely Sign files:
apps/contract-server/src/app/contract/dto/contract.external.dto.ts
apps/contract-server/src/app/contract/contract.service.ts
apps/contract-server/src/database/migrations/<timestamp>-DHubCompletionPolicyAndWebhookOutbox.ts
apps/contract-server/src/app/contract/__tests__/contract.external.service.spec.ts- Add request policy/notification snapshots and the webhook Outbox table.
- Validate policy labels against the selected template before contract creation/send.
- Reject same-key policy drift.
Slice C — Sign atomic completion and idempotent response
Likely Sign files:
apps/contract-server/src/app/contract/contract.service.ts
apps/contract-server/src/database/query/contract.query.ts
apps/contract-server/src/database/mysql.manager.ts
apps/contract-server/src/app/contract/contract.controller.ts
apps/contract-server/src/app/contract/contract.nonMember.controller.ts
co-located tests- Centralize DHub-managed completion instead of duplicating terminal logic across routes.
- Keep authorization before idempotent success.
- Ensure validation/Outbox failures roll back terminal status.
- Ensure post-commit side-effect failures cannot return an unknown completion result.
Slice D — durable webhook worker
Likely Sign files:
apps/contract-server/src/app/contract/dhub-webhook-outbox.service.ts
apps/contract-server/src/app/contract/dhub-webhook-outbox.worker.ts
apps/contract-server/src/app/contract/contract.module.ts
apps/contract-server/src/consumer.ts
co-located tests- Reuse the current modern envelope and timestamped-HMAC rules.
- Do not duplicate legacy callback configuration parsing in multiple services; extract one tested resolver/builder.
- Keep legacy callback behavior for non-DHub consumers outside this patch.
Slice E — DHub Inbox/reconciliation proof
Likely DHub files:
packages/api/src/routes/hereby-contract-webhook.routes.test.ts
packages/api/src/jobs/sign-webhook-inbox.job.test.ts
packages/api/src/services/annual-leave-promotion.service.test.ts
packages/test/e2e/<annual-leave-promotion>.spec.ts- Prove one valid completion event applies once.
- Prove webhook loss is repaired by Sign retry or DHub reconciliation without state regression.
- Preserve invalid terminal results as review-required.
6.1 Implementation record (2026-08-07)
- DHub: added
AnnualLeaveCompletionPolicywith immutable period/limit/row/field snapshots; S1 now sends it withnotificationPolicy=none. - Sign: added
completion_policy,notification_policy, andcompleted_atrequest snapshots plus migration1787000000000-DHubCompletionReliability. - Sign completion: added shared DHub-managed completion handling to member, non-member, and person completion routes. It authenticates before idempotent handling, validates the final persisted body, locks contract/request rows, commits terminal truth and one
contract.completedOutbox row together, and returns committed success on an authenticated retry. - Sign delivery: added
DHubWebhookOutboxServicewith lease-based claim/recovery, fresh timestamped HMAC per attempt, 2xx acknowledgement, bounded retry/jitter, andreview_requireddiagnostics; registered it in the consumer lifecycle. The volatile completion callback is skipped fornotificationPolicy=none. - Regression coverage: Sign policy/Outbox tests
8/8, Sign external API47/47, DHub API25files/310tests; DHub full build and Sign contract-server build pass. The changed DHub files pass Biome and both repositories passgit diff --check. - Known verification boundary: Sign full Jest is
33passing suites /1pre-existing failing suite (34suites,195passing tests) becausesrc/database/migration-ssl.spec.tscontains no tests. DHub full lint remains blocked by the repository baseline (19errors,1318warnings). Sign migration1787000000000-DHubCompletionReliabilityexecuted successfully, and the post-migration preview reports no pending migrations. No fresh E2E has been run.
7. Required Regression Matrix
Sign completion tests
- Empty plan: typed validation error; contract/request remain nonterminal; no Outbox row.
- Half-filled row: rejected without terminal mutation.
- Invalid/out-of-period/duplicate date: rejected.
- Zero, negative, non-integer, or non-30-minute amount: rejected.
- Total above the frozen limit: rejected.
- Valid one-row and multi-row plans: accepted.
- Outbox insert failure: contract and request terminal updates roll back.
- Concurrent duplicate completion: one completion timestamp and one Outbox row.
- Lost HTTP response after commit: authenticated retry returns idempotent success.
- Native notification, PDF, or MQ failure after commit: employee still receives success.
notificationPolicy=none: no Sign email/Kakao/SMS invocation on send, reminder, or completion.- Unauthorized caller cannot use the idempotent path to probe a completed contract.
Webhook Outbox tests
- Stable event/payload across retries; fresh delivery timestamp/signature each attempt.
- HTTP 200 marks delivered once.
- Timeout, connection failure, 429, and 5xx retry with backoff.
- 4xx/exhausted attempts retain diagnostics and become review-required.
- Worker crash after claim is recoverable.
- Two workers cannot deliver the same claim concurrently.
- Duplicate receiver acknowledgement remains safe.
DHub tests
- Completion Inbox insert is durable before HTTP 200.
- Duplicate/out-of-order event is a no-op.
- Valid signature-only result creates one
APPLIEDtransition and no employee plan rows. - Missing employee-plan fields are accepted; invalid signer/completion evidence remains
REVIEW_REQUIRED. - Reconciliation and webhook races apply once under row locking/CAS.
- No balance, leave request, email, or SMS mutation.
Run package tests and builds in both repositories. A Vitest/Jest pass without the relevant package build is not sufficient.
8. Contract 81 Disposition
Record contract 81 as the pre-Patch-6 failure fixture:
- Sign is completed with no employee plan data, which is valid under the corrected scope.
- Its external request did not reach completed status.
- No completion Inbox event exists.
- DHub is review-required.
Do not:
- edit its stored contract inputs after completion;
- replay a newly fabricated webhook;
- mark it applied;
- reset it to waiting;
- reuse its request key;
- delete its sessions, logs, contract, attempt, Inbox evidence, or promotion.
If a release-facing review UI needs a reason, use the existing DHub review-required error. Product design for resolving real review-required cases is outside this patch unless Eron separately approves that scope.
9. Post-Patch E2E
Precondition: a fresh designated QA employee/period and approved test contact data. If none exists, stop and request fixture authorization; do not reset contract 81.
- Run both migrations and restart Sign/DHub workers and web services.
- Send one first notice from DHub; verify one ticket, one request key, and one contract.
- Verify the early
signer_waitingevent produces HTTP 200 and one DHub Inbox row. - Verify one Messenger message, frozen title, sender snapshot, deadline, and CTA.
- Open one signer session.
- Verify the consent document has no required employee leave-plan fields.
- Add the required employee signature.
- Submit once; verify immediate success with no unknown-error flash.
- Verify Sign contract/request completed and exactly one completion Outbox event becomes delivered.
- Verify DHub has one processed completion Inbox event, no employee plan rows,
ESignRecordcompleted, attempt completed, promotionAPPLIED / completed, and one timeline event. - Repeat/reload the completion response; verify idempotent success/no duplicate event.
- Verify no duplicate contract/message/ticket charge, no leave balance deduction/request, and no Sign-native email/Kakao/SMS.
10. Acceptance Criteria
Patch 6 is complete only when:
- [x] Sign rejects an empty/invalid plan before terminal mutation with Korean corrective feedback (pure validator coverage; DB-backed route proof remains).
- [x] Valid DHub-managed completion atomically commits contract, request, timestamp, and one webhook Outbox event in the implementation (DB-backed rollback/concurrency proof remains).
- [x] A post-commit side-effect failure never changes a successful employee response into an unknown error in the DHub completion path.
- [x] An authenticated uncertain retry returns idempotent success in the DHub completion path.
- [x] Webhook delivery retries durably with the same event identity and DHub acknowledges only after Inbox insert in the implementation (worker/DB integration proof remains).
- [x] Execute Sign migration
1787000000000-DHubCompletionReliability; post-migration preview reports no pending migrations. - [ ] Valid completion applies DHub exactly once; invalid historical completion remains review-required.
- [x]
notificationPolicy=nonesuppresses the DHub first-notice native send and DHub-managed completion callback; full native invitation/reminder/completion channel matrix remains to be proven. - [x] Contract
81remains unchanged as audit evidence. - [ ] Sign/DHub focused tests, package builds, repository gates, and the fresh E2E pass.
11. Out of Scope
- Repairing or reopening contract
81. - Building a general review-resolution product workflow.
- Replacing Messenger with email, SMS, or Kakao.
- Changing leave balances or creating leave requests from a plan.
- Refactoring all legacy Sign callback consumers.
- Adding compatibility fallbacks for malformed policy versions or unlabeled templates.
- Deployment, rollout, commit, or push without separate authorization.