Skip to content

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-DHubCompletionReliability executed successfully. On 2026-08-10, Eron corrected the product scope to signature-only consent. Atomic completion, idempotent retry, durable webhook Outbox, and notificationPolicy=none remain 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 contract 81 data repair is authorized.

Safety rule: Contract 81 is terminal evidence. Do not reopen, delete, cancel, replay, fabricate a completion webhook for, or mark its DHub promotion applied. 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:

  1. the first employee completion request committed the Sign contract but returned an unknown error;
  2. the repeated request reported that the contract was already completed;
  3. Sign did not durably deliver a completion webhook to DHub;
  4. the then-current implementation incorrectly treated empty annual-leave plan rows as invalid;
  5. DHub reconciliation moved the case to review_required under 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 81 is terminal status = 3 with completed_at = 2026-08-07 05:22:38.
  • Its external request remains published, is not cancelled, and retains the 2026-08-16 deadline.
  • 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 81 exists in sign_webhook_inbox.
  • Reconciliation observed the remote terminal contract and fetched its result.
  • All annual_leave.plan.{1..20}.date and .minutes values were empty.
  • DHub recorded sign_completed_review_required with Sign 결과에서 연차 사용계획을 확인할 수 없어요..
  • The promotion is awaiting_signature / review_required; the attempt is review_required; ESignRecord still 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_required outcome 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:

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

Contract 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 ​

  1. Sign owns its local completion atomicity. For a DHub-managed contract, contract COMPLETE, external-request completed, immutable completion timestamp, and one webhook Outbox row commit in one MySQL transaction.
  2. 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.
  3. Modern DHub webhooks use a transactional Outbox. sendNotiCallback() is not the delivery boundary for DHub-managed completion events.
  4. 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.
  5. notificationPolicy=none covers 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.
  6. Superseded by Patch 7. Sign must not require or validate an employee leave-plan policy before terminal transition.
  7. 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.
  8. 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.
  9. Contract 81 stays 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:

ts
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' on dhub_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_days string 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:

  1. at least minimumRows contains both date and minutes;
  2. a row with only one side populated is invalid;
  3. dates are valid ISO dates, inside [periodStart, periodEnd], and unique;
  4. minutes are integers, positive, and divisible by stepMinutes;
  5. the total is <= maxTotalMinutes;
  6. only the expected employee signer supplied the editable plan/signature fields;
  7. 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:

  1. authenticate the signer/session and confirm recipient ownership before idempotent handling;
  2. start a MySQL transaction;
  3. lock contract and its dhub_external_contract_requests row;
  4. reload the final persisted body inside the transaction;
  5. validate required employee signature evidence; do not validate employee leave-plan fields or policy;
  6. if already complete with the same request and Outbox event, return the committed result idempotently;
  7. otherwise set contract.status = COMPLETE and one completed_at;
  8. set the external request to completed with the same terminal timestamp;
  9. insert exactly one immutable completion Outbox event;
  10. 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:

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

Constraints 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 LOCKED or 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:

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

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

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

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

text
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 AnnualLeaveCompletionPolicy with immutable period/limit/row/field snapshots; S1 now sends it with notificationPolicy=none.
  • Sign: added completion_policy, notification_policy, and completed_at request snapshots plus migration 1787000000000-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.completed Outbox row together, and returns committed success on an authenticated retry.
  • Sign delivery: added DHubWebhookOutboxService with lease-based claim/recovery, fresh timestamped HMAC per attempt, 2xx acknowledgement, bounded retry/jitter, and review_required diagnostics; registered it in the consumer lifecycle. The volatile completion callback is skipped for notificationPolicy=none.
  • Regression coverage: Sign policy/Outbox tests 8/8, Sign external API 47/47, DHub API 25 files/310 tests; DHub full build and Sign contract-server build pass. The changed DHub files pass Biome and both repositories pass git diff --check.
  • Known verification boundary: Sign full Jest is 33 passing suites / 1 pre-existing failing suite (34 suites, 195 passing tests) because src/database/migration-ssl.spec.ts contains no tests. DHub full lint remains blocked by the repository baseline (19 errors, 1318 warnings). Sign migration 1787000000000-DHubCompletionReliability executed 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 APPLIED transition 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.

  1. Run both migrations and restart Sign/DHub workers and web services.
  2. Send one first notice from DHub; verify one ticket, one request key, and one contract.
  3. Verify the early signer_waiting event produces HTTP 200 and one DHub Inbox row.
  4. Verify one Messenger message, frozen title, sender snapshot, deadline, and CTA.
  5. Open one signer session.
  6. Verify the consent document has no required employee leave-plan fields.
  7. Add the required employee signature.
  8. Submit once; verify immediate success with no unknown-error flash.
  9. Verify Sign contract/request completed and exactly one completion Outbox event becomes delivered.
  10. Verify DHub has one processed completion Inbox event, no employee plan rows, ESignRecord completed, attempt completed, promotion APPLIED / completed, and one timeline event.
  11. Repeat/reload the completion response; verify idempotent success/no duplicate event.
  12. 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=none suppresses the DHub first-notice native send and DHub-managed completion callback; full native invitation/reminder/completion channel matrix remains to be proven.
  • [x] Contract 81 remains 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.