MFS Annual Leave Promotion — Audited Delivery Plan
Execution rule: The two ticket files below plus the confirmed amendments in section 1.1 are the customer-requirement source. This plan may clarify implementation, but it must not replace Messenger with SMS/email, turn a display sender into a mailbox, or turn a promotion plan into a leave request. Eron approved the original Tasks 0–12 scope on 2026-08-05. The 2026-08-10 signature-only correction is now binding product scope, but this documentation update does not authorize Patch 7 code changes; obtain Eron's explicit approval before editing source, migrations, or tests. Commit, push, deployment, and rollout also require separate approval.
Current project status (2026-08-10): Eron confirmed that the employee flow is signature-only consent. The employee does not enter, format, or submit an annual-leave plan in Sign; any leave plan is submitted separately outside this workflow. Patch 6 reliability work remains required and is implemented locally: atomic terminal completion, authenticated idempotent retry, durable timestamped-HMAC Webhook Outbox, and
notificationPolicy=none. Its plan-policy code is now obsolete and must be removed through Patch 7, using a forward migration because Sign migration1787000000000-DHubCompletionReliabilityhas already executed. Release remains blocked until Patch 7 is implemented, the Patch 6 database/security gates pass, and a fresh signature-only E2E proves first-click success plus durable DHub completion. Contract81remains immutable historical QA evidence; its empty employee plan is expected under the corrected scope.
1. Source of Truth
Do not use docs/internal/message-sender-and-annual-leave-promotion-requirements-2026-08.md as a requirement source. It contains earlier Holder/SMS assumptions that are not in the two tickets.
The ticket files are immutable customer-requirement records. Do not rewrite them to reflect implementation decisions or defects found during delivery. This document is the project-level implementation source of truth: every later plan/patch remains independently readable, while its final decision, status, and release impact are summarized here.
1.1 Confirmed requirement amendments (2026-08-05)
These decisions supplement the ticket text and are binding for this plan:
- P1 supports an individual first notice and an admin-initiated manual batch first notice from annual-leave status. Batch selection supports explicit rows and an explicit
전체 대상 선택action that snapshots every currently eligible employee inside the admin's organization/permission scope for the selected period. - A batch automatically creates and sends one independent Sign contract per eligible employee and sends one independent Messenger notice per employee. Admin does not open Sign editor pages or click Send per contract.
- First notices are never created by a daily/periodic automatic-send rule. Every individual or batch first notice starts from an explicit admin action.
- Background workers may run continuously or periodically only to execute an already-submitted batch, reconcile Sign state, retry recoverable failures, and enforce deadlines.
- Second designation remains individual because each employee receives a separately authored schedule.
- Sign templates follow the existing organization-to-Sign-corporation integration. DHub lists only templates visible through the active organization credential, and Sign revalidates corporation ownership on create. Admin selects one template in the individual/batch first-notice flow; this is not an organization setting. A batch snapshots template ID, name, version/fingerprint, roles, and required labels.
- Admin selects a response duration, default 10 days. The ticket-defined response period starts at the committed Messenger
messagePublishedAt; DHub derives one exactexpiresAt = messagePublishedAt + responseDurationand synchronizes that absolute timestamp to Sign before marking first notice successful or enabling signer access. If Sign was created first and local recovery is delayed, expiry synchronization may extendprovisionalExpiresAtbut must never shorten the employee's full response period. - DHub and Messenger never store a reusable bearer signing URL. Sign returns a
recipientReference; after DHub checks employee identity, tenant, state, and deadline, it requests a signer-bound, revocable session URL valid for exactly 10 minutes. Both systems definenow >= expiresAtas expired and refuse session issuance/use immediately after contract completion, cancellation, or expiry. - Sign webhooks use timestamped HMAC authentication and carry
eventId,eventType,occurredAt, monotonicstatusVersion,contractId,requestKey, stableexternalOrganizationId, status, and terminal timestamps. Sign validates the external organization against the authenticated DHub corporation/API-credential mapping and snapshots it with the request. DHub uses it only as the organization lookup scope, verifies that organization's signature before mutation, conditionally binds the pre-createdESignRecordbyorganizationId + externalRequestKey, durably stores a webhook Inbox row, deduplicates byeventId, and processes state monotonically; reconciliation repairs lost callbacks. - Business status and technical processing status are separate. Technical failures use
QUEUED,PROCESSING,RETRY_WAIT,FAILED,SUCCEEDED, orREVIEW_REQUIRED; a failure never masquerades as a completed business outcome. Manual retry reuses the original request key and first determines whether a remote contract already exists. - A running batch supports
남은 발송 중지. Cancellation stops new claims, cancels only unstarted items, lets in-flight items settle, preserves already-sent contracts/messages, and records actor, time, and reason. - Delivery timestamps are explicit:
signSentAt,messagePublishedAt, optionalpushDeliveredAt, and optionalreadAt. Business success requires Sign sent, a persisted/queryable Messenger message, and Sign acknowledgement of the same DHub-derivedexpiresAt. Realtime/push/browser delivery is supplementary and is retried through a transactional Outbox without duplicating the message. ESignRecordis the sole source of truth for Sign integration identity and state.AnnualLeavePromotionowns the annual-leave business workflow; each immutableAnnualLeavePromotionSignAttemptreferences oneESignRecord, and the promotion points to its current attempt. Promotion/attempt business data never duplicatesexternalContractId, recipient reference, template identity, or remote timestamps.- S5 may extend a passed
provisionalExpiresAtonly while Sign remains atomically inPENDING_PUBLICATION, no signer session/native recipient notification was issued, and the request is neither completed nor cancelled. S5 and the preparation-expiry worker compete under a row lock/CAS: higher deadline revision wins only before a terminal transition.EXPIRED,CANCELLED, andCOMPLETEDare never revived. Confirmed preparation expiry/cancellation becomes retryable business failure with a new manually initiated request; ambiguous terminal/cancel state becomesREVIEW_REQUIRED. - Company-wide sending is one parent batch and one admin action, not one giant Sign API call. DHub snapshots eligible recipients, materializes one item per employee, internally inserts/claims work in chunks of 100, and creates one independent Sign contract/message per employee with per-organization concurrency 3 and global concurrency 10. Sign
429obeysRetry-After; the user does not have to submit additional batches merely because headcount exceeds 100. - Deadline cancellation is the explicit business state
EXPIRED_CANCELLED, rendered as요청 취소(기한 초과).지정 필요is a derived action/filter from server-calculatedcanSecondDesignate, not a replacement business state. - Workflow-message purpose includes
annual_leave_promotion_reminderin addition to first/second. Reminder history, rendering, notification, search, and idempotency use that distinct purpose. - S5 receives
plannedPublicationAt, notmessagePublishedAt. Sign loads the immutable response-duration snapshot stored by S1 and computes finalexpiresAt; only after S5 succeeds does DHub atomically publish the message withmessagePublishedAt = plannedPublicationAt. - Recoverable retries reuse the same Sign attempt/request key. Once Sign confirms an attempt
EXPIRED/CANCELLED, it is immutable: an explicit admin다시 발송creates a new attempt,ESignRecord, request key, hidden first-notice message, publication time, and deadline while retaining all old records. Ambiguous remote truth remainsREVIEW_REQUIREDand cannot reissue. - Batch progress UI renders the parent state independently of item counts, including
MATERIALIZING,CANCEL_REQUESTED, andCOMPLETED_WITH_ISSUES, so administrators can distinguish target generation, sending, stopping, and partial completion. 다시 발송is an individual row action available only for confirmedEXPIRED_CANCELLEDcases with no active/review processing. It is not part of the batch first-notice operation; the server remains the source of truth for whether the action is enabled.ESignRecord.externalRequestKeyis the sole persisted Sign request-key source of truth.AnnualLeavePromotionSignAttemptstores only theESignRecordrelation and local operation keys; webhook, retry, cancel, and remote-recovery lookups resolve the request key through the organization-scoped e-sign record.- The P1 template compatibility contract assigns every form/signature component to the employee and includes only server-prefilled consent evidence such as employee identity, period snapshot, remaining balance, notice, and deadline. The existing admin-import Manifest may include Holder as the technical sender with no assigned field, signature, seal, or approval step. The template must not require employee-editable leave-plan fields or a leave-plan format.
- A DHub-managed Sign contract becomes completed after the required employee signature succeeds. The Sign contract terminal state, external-request completed state, terminal timestamp, and one durable webhook Outbox event commit in one local transaction. Webhook/native notification delivery is post-commit and cannot change the employee response; authenticated uncertain completion retries return the committed success idempotently.
- The employee's annual-leave plan is submitted separately outside this product flow. DHub and Sign do not collect, validate, normalize, display, or persist it. This does not change the company-authored schedule used only after an eligible second designation.
1.2 Project document map
| Order | Record | Role | Current status |
|---|---|---|---|
| 0 | TC20 source ticket | Immutable customer requirement | Original text preserved |
| 0 | P1 source ticket | Immutable customer requirement | Original text preserved |
| 1 | This audited delivery plan | Project index, final architecture, phased delivery, release gates | Living implementation SoT |
| 2 | Current-page selection plan | Standard table selection and global-scope separation | Implemented; targeted tests/admin build/full build passed |
| 3 | Individual retry and Sign -152 repair | Same-request retry, server snapshots, actionable ticket error | Implemented; local DHub migration/fingerprint recovery completed during QA |
| 4 | Sign -117 invalid-body/idempotency repair | Draft hydration, request-to-draft binding, retry/reconciliation | Implemented; orphan-draft review remains |
| 5 | First-webhook binding-race repair | Signed tenant reference, early contract binding, durable Inbox | Implemented; migration executed, security matrix and fresh E2E remain |
| 6 | State consistency and workflow-title repair | Waiting reconciliation, atomic S5 publication truth, frozen Messenger title | Implemented; employee title/session QA passed, database concurrency proof and release gates remain |
| 7 | Sign completion reliability repair | Atomic completion, durable webhook Outbox, idempotent response; historical plan-policy implementation | Reliability code implemented; plan-policy portion superseded by Patch 7 |
| 8 | Signature-only scope correction | Remove employee plan collection/validation while preserving completion reliability and company second designation | Planned; code changes require Eron's approval |
Detailed diagnostics, file-by-file instructions, and focused test cases stay in the linked records. When a linked record conflicts with this document, the newest explicitly confirmed final decision must be reconciled into this document before more implementation begins; Agents must not silently choose one.
1.3 Implementation and repair ledger
Phase 0 — requirement and architecture
- Read the two source tickets and separated the message display sender from SMTP/shared-mailbox behavior and from the Sign Holder identity.
- Confirmed P1 is Messenger-first, admin initiated, supports individual and deliberate manual batch first notice, and requires coordinated DHub + Sign work.
- Expanded the original ticket into the audited Tasks 0–12, state machines, saga boundaries, template contract, batch model, permission scope, deadlines, Inbox/Outbox, and release gates below.
Phase 1 — primary implementation
- Added the TC20 organization setting and owner initialization paths.
- Added workflow-message sender snapshots and Messenger workflow behavior.
- Added annual-leave promotion entities, migrations, state/service/router/SDK/admin flows, individual and batch processing, expiry/reconciliation/outbox jobs, and selection UX.
- Added Sign external create/send, deadline, cancel/result/signer-session, template compatibility, idempotent request records, and DHub webhook metadata.
- Work remains uncommitted in the current DHub and Sign working trees; preserve unrelated and pre-existing edits.
Patch 1 — table bulk-selection semantics
- Problem: the annual-leave table had row selection but no standard header selection state.
- Final decision: header checkbox controls the current filtered page;
전체 대상 선택remains a separate server-scoped operation. - Result: implemented tri-state current-page selection, cross-page explicit selection, filter resets, visible global-all mode, and focused coverage.
- Detail: current-page selection implementation record.
Patch 2 — Sign ticket shortage and individual retry
- Observed: Sign returned
-152 NOT_ENOUGH_TICKET; DHub correctly preserved a retryable failure, but the UI generated a new request key. - Final decision: retry accepts only
promotionId; the server reloads immutable request snapshots and reuses the original promotion, attempt, e-sign record, andexternalRequestKey. Ticket validation is never bypassed. - Result: implemented
재시도, Korean-152mapping, template-fingerprint snapshot/migration, concurrency claim, and same-request retry. The local QA fingerprint recovery was performed before the successful send. - Detail: individual retry and
-152repair.
Patch 3 — Sign -117, orphan drafts, and remote idempotency
- Observed: automatic create/send passed empty persisted
body/userstofinalEdit, causing-117 INVALID_CONTRACT_BODY; failures left request rows withoutcontract_idand created orphan drafts. - Final decision: hydrate server-owned
editingData, bind the draft to the idempotency request before finalization, serialize concurrent requests with a named lock, resume the linked draft, and reconcile already-finalized contracts without another ticket charge. - Result: valid automatic send no longer fails with
-117; contract81reachedsigner_waiting, ticket use and deadline synchronization returned201. Genuine malformed templates still map to an actionable Korean error. - Remaining: inspect and safely retire the older local orphan drafts with exact predicates; never resend contract
81to repair historical errors. - Detail: Sign
-117repair handoff.
Patch 4 — first-webhook contract-binding race
- Observed sequence: Sign emitted
signer_waitingwhile create/send was returning; DHub had pre-createdESignRecord.externalRequestKeybut had not yet savedherebyContractId=81, so the webhook returned404 Record not found. - Final decision: DHub supplies
externalOrganizationId; Sign validates and snapshots it and includes it in the HMAC-signed event. DHub resolves the active credential in that organization, verifies the signature, finds the pre-created record byorganizationId + externalRequestKey, conditionally bindscontractId, then durably inserts/acknowledges the Inbox event. - Result: receiver and sender code are implemented. Focused verification recorded Sign 47/47 tests, DHub webhook/inbox 6/6 tests, DHub API build, and Sign no-emit type-check passing.
- Migration status: Sign migration
1786000000000-DHubExternalOrganizationReferencehas executed locally. - Remaining: rerun the Sign full build, complete the planned cross-tenant/conflict/missing-field security matrix, and perform a fresh post-patch send/sign/complete E2E.
- Detail: webhook record-binding race plan and implementation record.
Patch 5 — waiting-state, deadline SoT, and workflow-title consistency
- Observed: contract
81is valid and waiting, its Messenger message/Outbox were delivered, and Sign plus promotion/attempt hold the final deadline; however, reconciliation leftAnnualLeavePromotion.processingStatus=processing,ESignRecord.expiresAtremained null, and the employee Messenger rendered the body/CTA without the frozen연차 사용촉진 안내title. - Final decision: a non-completed Sign result must leave the stable processing status unchanged; only confirmed completion may CAS into completion processing. After S5, e-sign deadline/revision, attempt, promotion, Message publication, unread state, timeline, and Outbox commit in one local transaction. Workflow titles are persisted snapshots, serialized through history/realtime, and rendered separately from the conversation title and body.
- QA boundary: do not resend, cancel, complete, or delete contract
81to repair local state. Guarded local repair may fill only unambiguous deadline/status rows. The historical missing first Inbox event remains accepted pre-patch evidence. - Result: implemented migration
1879000000000-AddWorkflowMessageTitle, atomic post-S5 local persistence/publication, canonical deadline checks, waiting-state restoration, workflow-title persistence/history/realtime/rendering, and guarded repair of contract81without resending or additional ticket use. - Verification at the pre-completion Patch 5 checkpoint: DHub API
25files/310tests, Components16files/289tests, API/Components builds, and repositorypnpm buildpass. Contract81had processingcompleted; its e-sign record, attempt, and promotion shared the same deadline/revision; the original Outbox was delivered and signer session count was zero before the authorized employee click. - Terminal-status fix:
shouldRestoreWaitingProcessingStatus()now accepts only remotein_progress;completed,cancelled, andexpiredno longer enter the waiting-state restoration path. - Remaining proof: add database-backed rollback/concurrent reconciliation coverage, complete employee-side title/session QA, address the repository lint baseline, and finish the final completion E2E.
- Detail: state consistency and workflow-title repair plan.
Patch 6 — Sign terminal completion reliability
- Observed: contract
81committedstatus=3on the first employee completion click but returned an unknown error; the second click reported an already-completed contract. No completion event reached DHub Inbox. - Result evidence: Sign completion/input logs exist, while the external request remains
published. Reconciliation did not receive a durable completion event. Its laterreview_requiredresult came from employee-plan validation that is now superseded. - Final decision: Sign atomically commits contract completion, external-request completion, the terminal timestamp, and one webhook Outbox row. Delivery retries independently,
notificationPolicy=nonesuppresses Sign-native lifecycle notifications, and authenticated uncertain completion retries return idempotent success. The Patch 6 plan-policy portion must be removed by Patch 7. - QA boundary: contract
81is immutable failed evidence. Do not reset, apply, replay, delete, or reuse it; use a fresh designated QA employee/period after Patch 6 passes automated gates. - Status: implementation and Sign migration complete in the local working trees; database-backed rollback/concurrency proof, repository gates, and a fresh full E2E remain.
- Detail: Sign completion reliability repair plan.
Patch 7 — signature-only scope correction
- Confirmed requirement: the Sign document is an annual-leave-promotion consent form. The employee completes the workflow by signing; the employee's leave plan and its format are outside the system.
- Final decision: remove employee plan labels, policy snapshots, validation, normalization, persistence, result display, and plan-specific copy. Preserve company-authored second-designation schedules and all Patch 6 reliability guarantees.
- Migration rule: migration
1787000000000-DHubCompletionReliabilityalready ran, so never rewrite it; add a forward migration that removes only obsolete completion-policy storage and preserves webhook Outbox and notification-policy schema. - Status: planned; no Patch 7 production-code change is authorized by this documentation update.
- Detail: signature-only scope-correction plan.
1.4 Current operational truth and next gates
- Contract
81is terminal failed-E2E evidence: Sign completed it at2026-08-07 05:22:38, but its external request remainspublishedand no completion Inbox event exists. Its empty employee plan is valid under the corrected scope; DHub'sawaiting_signature / review_requiredstate was produced by obsolete validation, not intended business behavior. - Contract
81has both the historical first-webhook miss and the new completion-webhook miss. Do not repair either by replaying fabricated events or changing terminal/business truth. - Sign migrations
1786000000000-DHubExternalOrganizationReferenceand1787000000000-DHubCompletionReliability, plus DHub migration1879000000000-AddWorkflowMessageTitle, have run locally; the post-migration preview reports no pending migrations. Restart Sign and DHub before the next manual send and use only designated test contact data. - Contract
81still proves Messenger publication, Outbox delivery, one-contract/no-extra-ticket behavior, title rendering, and signer-session access. It does not prove valid completion or reliable webhook delivery. - Employee visual/title/session QA passed. Final signature/completion QA failed and must not be repeated on contract
81. - The next fresh request is prohibited until Patch 7 is implemented and the remaining Patch 6 automated gates pass. It must then prove: no employee plan fields, one ticket, one contract, first webhook
200, one Inbox event per status, one Messenger message, one signer session per click, immediate signature completion success, durable completion Outbox delivery, and DHubAPPLIED / completed. - Release remains blocked by Patch 7 implementation, Patch 6 database-backed concurrency/rollback proof, the complete webhook security matrix, repository lint baseline, orphan-draft decision, fresh signature-only E2E, commit/review, and separately authorized rollout.
2. Goal and Problem Boundary
Deliver two customer capabilities:
- TC20 — workflow message display sender: An organization setting controls the name and email shown on official internal messages. New workspaces copy the owner values once. Later owner changes do not synchronize the setting.
- P1 — annual-leave promotion: Admin sends an individual or manual batch first Messenger notice containing an employee-safe hereby Sign link; Sign completion applies the promotion; an incomplete request is cancelled after the deadline; admin then designates a schedule and sends the reason and schedule through Messenger.
The concepts must remain separate:
| Concept | Meaning | Source of truth |
|---|---|---|
| Message display sender | Name/email shown on official internal messages | Organization.settings.messageSender |
| Executor | Admin who clicked send/designate | Authenticated DHub employee ID/audit event |
| Sign Holder | Account that owns the Sign API/corporation integration | Existing Sign provisioning |
| Generic Sign creator | Existing identity for generic templates; P1 rejects templates requiring a company-side signer | Existing Organization.settings.sign |
| Employee recipient | Employee receiving the Messenger notice | DHub employee |
| Employee signer | Non-member Sign participant opening the recipient link | Sign contract participant |
The configured message email is display metadata. It is not a shared mailbox, SMTP credential, recipient address, domain authorization, Sign account, or Holder-provisioning input.
3. Verified Pre-Implementation Audit
This section records the baseline observed before implementation on 2026-08-05. It is historical context, not the current working-tree status. For the implemented state, repairs, and remaining gates, use sections 1.2–1.4.
3.1 DHub
- Organization settings already have
settings.sign, but no independent message-sender setting. - Workspace signup initializes
settings.signfrom owner. System-admin provisioning has a separate organization-creation path and must be updated independently. Messagehas no official-message origin, display-sender snapshot, structured action link, business reference, or idempotency key.- Ordinary Messenger direct conversations always contain the current user and employee. The employee sees the real admin as the room counterpart.
- Messenger renders plain content with
x-text; a Sign CTA needs structured action metadata rather than HTML in message content. - Messenger realtime/push payloads currently derive sender name from the actual employee.
- Annual-leave status uses
leaveRequest.listBalances, scope helpers, selected-yearasOfDate, and leave-ledger balance calculation. There is no promotion state. - Annual-leave status has no row selection or durable batch-send concept for promotion.
- Generic e-sign requires employee email and stores a non-null
recipientEmailSnapshot. - The Sign webhook contains status only. DHub has no completed-field result API and no Sign cancellation API.
- Current jobs are process timers. A compliance job therefore needs durable database claim/retry state.
3.2 hereby Sign
P1 cannot be completed by DHub-only changes:
POST /contract/external/create-from-templatecreates a draft and returns a Holder/Admin/editor/:contractIdURL with a Holder login token.- That URL is not an employee signing URL and must never be placed in Messenger.
recipientEmailis mandatory in the current external DTO.- Sign already supports non-member invite links using opaque origin access and a DHub external signer marked for name-based access.
- The external API does not expose one-call create-and-send returning the employee invite URL.
- Sign has internal cancellation, but no external authenticated cancellation endpoint.
- External status/webhook data contains no completed component values.
api_contract_recordshas no external request key, so a crash after remote creation can duplicate contracts on retry.
3.3 Mandatory conclusion
P1 requires coordinated changes in both repositories:
/Users/joshua/Project/FIG/hereby/hereby-sign/Users/joshua/Project/FIG/hereby/hereby-dhub
Implementation baseline is the current develop branch in both repositories after the Holder/provisioning and internal HTTP integration work was merged:
- DHub:
aa20f041(PR #526) - Sign:
897cb21(PR #276)
Before coding, verify the existing chain instead of rebuilding it: DHub resolves the active Sign credential by organizationId, Sign maps it to one corporation, DHub lists that corporation's templates, and Sign revalidates template corporation ownership when creating a contract. P1 extends this chain with template selection/snapshot and create-and-send; it must not introduce a second Holder/template mapping.
4. Requirement Interpretation
In scope
- Official workflow-message display name/email setting and owner initialization.
- Dedicated internal Messenger delivery for annual-leave promotion.
- First notice, structured Sign CTA, deadline, state, executor, and timeline.
- Sign template creation and actual sending to a non-member employee signer.
- Sign completion result ingestion and promotion application.
- Automatic remote Sign cancellation after the deadline.
- Second company designation and Messenger notice containing reason plus complete schedule.
- Promotion status in annual-leave status and original Sign record in the existing e-sign list.
- Admin-selected manual batch first notice, durable batch/item progress, partial-failure reporting, and failed-item retry.
- Tenant scope, permission scope, immutable history, retries, and operational recovery.
Out of scope
- Shared mailbox, SMTP/OAuth, domain verification, or real email
Send As. - Configurable Sign Holder email.
- Replacing Messenger with SMS/email. P1 disables Sign-native SMS/email; any additional channel requires a later ticket.
- Daily/periodic target discovery or automatic first-notice sending.
- Batch second designation.
- Signing inside DHub/Works.
- A second Sign request after company designation.
- Creating
LeaveRequest, posting leave-ledger entries, or reducing balance from a plan/designation. - Labor-receipt refusal, forfeiture/pay gates, or unrelated annual-leave compliance phases.
5. Product Decisions and Freeze Status
All decisions below are CONFIRMED and binding for the implementation once the execution rule's code approval is obtained. The Agent must not reopen them unless current code proves the decision technically impossible or unsafe.
| ID | Status | Decision | Binding/recommended implementation |
|---|---|---|---|
| D1 | CONFIRMED | Ticket says both “10 days” and “deadline must be editable.” | Edit response duration, default 10 days. Derive expiresAt from committed messagePublishedAt; synchronize that exact absolute timestamp to Sign. Both systems expire at now >= expiresAt. |
| D2 | CONFIRMED | Pending-request resend policy. | Reuse the active contract/reference and original deadline, append one versioned reminder message plus audit event, and never create another active contract. |
| D3 | CONFIRMED | Exact first/second Korean copy. | Use the fixed, versioned, server-owned templates in section 5.1. Admin sees preview only; copy and second-designation reason are not editable. |
| D4 | CONFIRMED | Platform fallback sender and help text. | Display fallback is hereby / noreply@hereby.me (the configured platform EMAIL_FROM; test default shown). Help text is 비워두면 플랫폼 기본으로 발송됩니다. 워크스페이스 생성 시 owner 정보로 초기화돼요. This remains display metadata, not mailbox authorization. |
| D5 | CONFIRMED | Official-message room/reply behavior. | Use one non-reply official workflow conversation per organization/employee. Do not inject company messages into arbitrary admin DMs. |
| D6 | CONFIRMED | Sign-native email/SMS behavior. | Messenger is mandatory and notificationPolicy = none for P1. Sign-native SMS/email is out of scope. |
| D7 | CONFIRMED | Company second-designation schedule units/validation. | Applies only when the company designates dates after an eligible expired request. One row per ISO date; unit is full day, half day, or hours in 30-minute steps. Use existing org/employee leave-unit conversion; require date inside the period, unique date, positive amount, and total not above remaining balance. Weekend/holiday/non-workday produces a warning but does not block shift workers. No employee plan collection and no leave request/ledger mutation. |
| D8 | CONFIRMED | Promotion target period. | Bind to server-resolved annual-leave periodKey for the selected balance year and snapshot period start/end. Never use calendar year alone. |
| D9 | CONFIRMED | Eligible employee. | Active, non-deleted, permission-scoped employee with resolvable period, remaining balance greater than zero, and no conflicting active promotion. Worker revalidates every item. |
| D10 | CONFIRMED | Company-side signer. | P1 requires employee-only form/signature components. The admin-importer's required Holder entry is sender metadata only and owns no component. A template requiring company-side signature, seal, or approval is rejected from selection; adding that workflow requires a later ticket. |
| D11 | CONFIRMED | Batch items must each receive the full response duration. | Batch modal edits one response duration, default 10 days. Each successful item's deadline is derived from its own committed messagePublishedAt, so later-processed employees do not lose response time. |
| D12 | CONFIRMED | Company-wide batch selection and capacity. | Support EXPLICIT_IDS and deliberate ALL_ELIGIBLE_IN_SCOPE. Server preflight snapshots exact IDs/count/hash into a 15-minute selection token; submit uses that token and workers revalidate. One parent batch may cover the full eligible company population; internal materialization/claim chunks are 100, per-org concurrency is 3, global concurrency is 10, and 429 obeys Retry-After. |
Approved decisions must be recorded in a ticket, this plan's confirmed-amendment section, or a linked decision note, not only in chat history.
5.1 Frozen Korean workflow copy (v1)
First notice title: 연차 사용촉진 안내
{employeeName}님, 연차 사용촉진 제도를 안내드려요.
현재 잔여 연차는 {remainingLeaveDisplay}예요.
{deadlineDisplay}까지 연차 사용촉진 동의서에 서명해 주세요.
서명만으로 연차가 신청되거나 잔여 연차가 차감되지는 않아요.CTA: 서명하기
Reminder title: 연차 사용촉진 동의서 서명 안내
{employeeName}님, 연차 사용촉진 동의서 서명이 아직 완료되지 않았어요.
제출 기한은 {deadlineDisplay}이며, 이번 안내로 기한은 변경되지 않아요.
기한 전까지 서명을 완료해 주세요.Reminder CTA: 서명하기
- Reminder version is
annual-leave-promotion-reminder-v1. - Reminder is allowed only while business state is
AWAITING_SIGNATURE, processing state is signable, andnow < expiresAt. - It creates a new workflow message/audit event with a stable reminder operation key and the current display-sender/executor snapshot, but reuses the original promotion reference, immutable Sign-attempt CTA reference,
ESignRecord, recipient reference, Sign contract, and deadline. It never calls S1/S5 or resets the response period.
Second designation title: 연차 사용시기 지정 안내
{employeeName}님, 연차 사용촉진 동의서 서명 기한이 지나 회사가 연차 사용시기를 지정했어요.
지정 사유: 제출 기한 내 동의서 서명 미완료
지정 일정
{scheduleDisplay}
이 안내는 연차 신청이나 잔여 차감 처리가 아니에요.Copy variables are server-rendered, escaped, and tested in the organization timezone. Snapshot versions are annual-leave-promotion-first-v1, annual-leave-promotion-reminder-v1, and annual-leave-promotion-second-v1. Admin cannot edit any body/reason in P1.
5.2 Company-wide selection contract
EXPLICIT_IDS: the admin checks specific visible rows.ALL_ELIGIBLE_IN_SCOPE: the admin deliberately clicks전체 대상 선택; it means every currently eligible employee for the selected period inside the authenticated permission scope, not merely the loaded page or current client cache.- Preflight returns exact eligible/skipped counts and creates a 15-minute, tenant/actor/period/filter-bound opaque token referencing a server-side selection snapshot. The token contains no employee list; the database snapshot owns the exact IDs/hash. Confirmation displays the scope and exact count.
- Submit consumes the token idempotently and creates one parent batch. Expired/stale tokens require another preflight; filters or year changes never silently retarget a token.
- The database materializes batch items in chunks of 100. Chunk size is internal and does not force the admin to submit again. Workers still revalidate each employee and preserve per-item success/skip/failure truth.
6. Target Architecture
6.1 Official workflow conversation
Do not reuse ordinary direct messages. Introduce ConversationType.WORKFLOW:
- One active workflow conversation per
organizationId + recipientEmployeeId. - Only the recipient is a conversation member. The admin remains executor, not a fake chat participant.
- Room display metadata comes from the current resolved message-sender setting.
- Each message snapshots name/email, so history does not change after setting edits.
- Employee cannot reply, leave, rename, add members, or convert the room. API enforces this and UI hides those actions.
- Ordinary direct/group/team conversations remain unchanged.
6.2 Structured workflow message
Add server-only metadata:
origin = workflow
purpose = annual_leave_promotion_first | annual_leave_promotion_reminder | annual_leave_promotion_second
displaySenderNameSnapshot
displaySenderEmailSnapshot
action = { kind: annual_leave_promotion_sign, label, signAttemptReferenceId }
referenceType = annual_leave_promotion
referenceId = promotionId
operationKey
senderId = real executor employee IDRules:
- Client
message.sendcannot submit these fields. operationKeyis unique per organization.- Render the action as a CTA, never as injected HTML.
- Do not create, persist, log, or publish a long-lived bearer Sign invite URL in DHub, Message, realtime events, push payloads, search results, or HTML.
- The CTA stores only internal promotion and immutable Sign-attempt references. On click, a scoped DHub procedure verifies employee/tenant ownership, that the referenced attempt is still the promotion's current signable attempt, business/processing state, and
now < attempt.deadlineAt, then calls Sign S6 with that attempt'sESignRecordrecipient reference. Old-attempt CTAs never redirect to a newer contract. - Sign S6 returns a signer/contract-bound, revocable session URL valid for exactly 10 minutes. DHub validates HTTPS plus the configured Sign origin and redirects without persisting the session URL.
- Sign refuses to issue a session after completion, cancellation, or expiry. A copied expired session URL cannot open a new signing session.
- Realtime, preview, browser notification, and push use display snapshots for workflow messages.
- Workflow messages show both configured name and email. Ordinary messages keep the employee sender.
6.3 Promotion aggregate
Use dedicated entities; never reuse leave-request or ledger entities.
AnnualLeavePromotion:
organizationId
employeeId
periodKey
periodStartSnapshot
periodEndSnapshot
businessStatus
currentSignAttemptId?
secondDesignationActorId
secondDesignationReasonSnapshot
secondDesignatedAt
processingStage
processingStatus
lastErrorCode
lastErrorAt
createdAt / updatedAt / deletedAtOne non-deleted case is allowed per organizationId + employeeId + periodKey.
no record
-> NOT_NOTIFIED
-> AWAITING_SIGNATURE
-> APPLIED
AWAITING_SIGNATURE
-> EXPIRED_CANCELLED
EXPIRED_CANCELLED
-> SECOND_DESIGNATED
-> AWAITING_SIGNATURE (explicit reissue succeeds)- Business state is
NOT_NOTIFIED | AWAITING_SIGNATURE | APPLIED | EXPIRED_CANCELLED | SECOND_DESIGNATED. - Render
EXPIRED_CANCELLEDas요청 취소(기한 초과). DerivecanSecondDesignate/지정 필요only when the case isEXPIRED_CANCELLED, no reissue/cancellation/review processing is active, permission/target/balance rules pass, and no second designation exists. - Technical state is
QUEUED | PROCESSING | RETRY_WAIT | FAILED | SUCCEEDED | REVIEW_REQUIRED, paired with a stage such as first notice, completion sync, cancellation, or second designation. FAILEDmeans automatic attempts are exhausted. A recoverable manual retry reuses the current attempt/operation/request key. Before remote creation, query by request key: recover an existing nonterminal contract when present and create only when Sign confirms absence.- A confirmed terminal
EXPIRED/CANCELLEDattempt never reuses its request key orESignRecord. Explicit다시 발송creates a new attempt/record/key; the aggregate remainsEXPIRED_CANCELLEDwhile reissue is processing and changes toAWAITING_SIGNATUREonly when the new notice is successfully published. REVIEW_REQUIREDcovers contradictions such as Sign completed but signer identity, template ownership, tenant binding, or completion evidence validation failed. Missing employee-plan data is not a contradiction. The state preserves evidence and requires an audited operator decision; it does not automatically apply or cancel the promotion.- Processing states expose Korean processing/retry/manual-review UI, not false business completion.
- Each Sign attempt's terminal transitions are monotonic. The promotion may re-enter
AWAITING_SIGNATUREonly through an audited explicit reissue with a new attempt. - Sign-completed wins only when the current attempt atomically reports completion before cancellation.
EXPIRED_CANCELLEDis reached only after Sign confirms cancelled/already-cancelled due to deadline expiry. Second designation is allowed only when derivedcanSecondDesignateis true.
AnnualLeavePromotionSignAttempt:
organizationId / promotionId / attemptNumber
eSignRecordId
status = PREPARING | AWAITING_SIGNATURE | APPLIED | EXPIRED_CANCELLED | FAILED | REVIEW_REQUIRED
firstNoticeOperationKey / firstNoticeMessageId
messagePublishedAt? / deadlineAt?
firstNoticeActorId / reissueReason?
endedAt?
createdAt / updatedAt / deletedAt- Unique constraints cover
organizationId + promotionId + attemptNumberandeSignRecordId. The Sign request key is unique and queried only throughESignRecord.organizationId + externalRequestKey; the attempt does not keep a writable or independently queryable copy. AnnualLeavePromotion.currentSignAttemptIdpoints to the latest attempt; old attempts, e-sign records, messages, and events remain immutable and queryable in the timeline/e-sign list.- Webhook/status/result/cancel/session resolution is
ESignRecord -> SignAttempt -> Promotion. Same-organization checks apply at both links.
ESignRecord is the only local Sign integration SoT and stores:
organizationId
externalContractId?
externalRequestKey
externalRecipientReference?
templateIdSnapshot / templateNameSnapshot / templateVersionSnapshot
remoteStatus / statusVersion
deadlineRevision / deadlineSyncStatus
responseDurationSecondsSnapshot
signSentAt / provisionalExpiresAt / expiresAt? / completedAt / cancelledAt
recipientNameSnapshot / recipientEmailSnapshot? / recipientPhoneSnapshot?
createdAt / updatedAt / deletedAt- Unique constraints cover
organizationId + externalContractIdandorganizationId + externalRequestKey. - Before S1, DHub creates the attempt and its
ESignRecordwith request key/template snapshot while contract/recipient fields remain null, then atomically setscurrentSignAttemptId. S1 success fills remote fields; recoverable retries reuse the same attempt/row/key. - All assignment and lookup paths verify that promotion, attempt, and e-sign record share the same
organizationId.
AnnualLeavePromotionPlanItem stores only company-authored second-designation schedule rows using frozen D7 units/validation. It must not store employee leave plans.
AnnualLeavePromotionEvent is append-only and stores event type, actor, time, signAttemptId where relevant, display snapshot, and sanitized metadata.
6.4 Manual batch aggregate and worker
Batch execution is a durable DHub orchestration layer, not a Sign editor workflow and not a browser loop.
AnnualLeavePromotionSelectionSnapshot:
organizationId / actorId / periodKey
selectionMode / filterSnapshot
tokenHash / employeeIdsSnapshot / employeeIdsHash
eligibleCount / skippedCount
expiresAt / consumedAt? / consumedByBatchId?
createdAt / updatedAt / deletedAt- The client receives only the opaque token. Lookup always includes organization/actor and compares its hash; raw employee IDs never travel inside the token.
- The snapshot is not a promotion/notice. It expires after 15 minutes and is one-batch consumable with idempotent
clientRequestKeyrecovery. After full item materialization, clear the duplicated employee-ID array and retain hash/count/batch reference; expired unconsumed snapshots are ID-redacted and soft-deleted after 24 hours.
AnnualLeavePromotionBatch:
organizationId
selectedYearSnapshot
selectionMode = EXPLICIT_IDS | ALL_ELIGIBLE_IN_SCOPE
selectionSnapshotHash / selectionCount
status = MATERIALIZING | QUEUED | RUNNING | CANCEL_REQUESTED | CANCELLED | SUCCEEDED | COMPLETED_WITH_ISSUES | FAILED
clientRequestKey
actorId
senderNameSnapshot
senderEmailSnapshot
messageTemplateVersionSnapshot
signTemplateIdSnapshot
signTemplateVersionSnapshot
deadlineDurationSnapshot
totalCount / materializedCount / queuedCount / processingCount / succeededCount / skippedCount / failedCount / cancelledCount / reviewRequiredCount
materializationCursor?
cancelRequestedAt? / cancelledAt? / cancelledBy? / cancellationReason?
startedAt / completedAt
createdAt / updatedAt / deletedAtAnnualLeavePromotionBatchItem:
organizationId
batchId
employeeId
periodKey
promotionId?
status = QUEUED | PROCESSING | RETRY_WAIT | SUCCEEDED | SKIPPED | FAILED | CANCELLED | REVIEW_REQUIRED
operationKey
attemptCount
nextAttemptAt?
failureCode? / sanitizedFailureDetail?
claimedAt? / claimedBy?
startedAt? / completedAt?
createdAt / updatedAt / deletedAtRules:
- One item exists per employee in the consumed server-side selection snapshot; unique
batchId + employeeIdandorganizationId + operationKeyprevent duplicates. ALL_ELIGIBLE_IN_SCOPEis an explicit admin choice backed by the 15-minute server snapshot. It covers all eligible employees in scope, not the loaded page. One parent batch entersMATERIALIZINGand idempotently creates items in chunks of 100 under uniquebatchId + employeeId; process restart resumes from durable truth. Chunking is never exposed as multiple admin sends.- Admin selects one template from the existing organization credential's corporation-scoped Sign template list. Batch submission snapshots sender, copy version, template ID/name/version/fingerprint/roles/required labels, and deadline duration once so mid-batch setting or template edits do not split the batch identity.
- The worker revalidates current corporation ownership, required roles/labels, and expected version/fingerprint before every create. A mismatch fails safely; it never silently switches template/version.
- The server revalidates permission, tenant scope, employment, period, existing active case, and Sign readiness for every item. Stale/ineligible items become
SKIPPEDwith a Korean reason; the client cannot pre-authorize them. - API submission atomically consumes the snapshot, persists the parent batch, and returns
batchIdimmediately. A durable materializer creates/resumes item chunks, then moves the batch toQUEUED; navigating away or restarting an API instance does not stop work. - A durable worker claims queued/retryable items with a database-safe lock, processes them with bounded Sign concurrency/rate limiting, and invokes the same single-item saga used by row send.
- An item becomes
SUCCEEDEDonly after Sign reports formally sent, DHub prepares the Messenger message, Sign acknowledges the DHub-derived absolute deadline, and DHub atomically publishes the message/unread state with thatmessagePublishedAt, promotion state, timeline, and notification Outbox event. - One item failure never rolls back another item. Recoverable failures use the same item and Sign request key; manual retry targets only failed items.
남은 발송 중지atomically marks a materializing/running batchCANCEL_REQUESTED, prevents new Sign work, and marks/materializes every not-yet-started snapshotted employee as aCANCELLEDitem in resumable chunks so per-employee truth remains complete. Already claimed items settle to their real result; already-sent Sign contracts/messages remain valid and are not automatically withdrawn.- When no queued/processing items remain, cancellation resolves to
CANCELLEDorCOMPLETED_WITH_ISSUESaccording to item truth. The timeline records actor, request time, completion time, reason, and counts. - Re-submitting the same
clientRequestKeyreturns the original batch. It never creates a second batch or duplicate contracts. - No scheduler discovers employees or creates first-notice batches. Workers only execute batches explicitly submitted by an admin.
- Second designation never uses this batch aggregate.
Parent batch UI labels are fixed and distinct from item/promotion states:
MATERIALIZING -> 발송 대상 생성 중
QUEUED -> 발송 대기
RUNNING -> 발송 중
CANCEL_REQUESTED -> 남은 발송 중지 중
COMPLETED_WITH_ISSUES -> 일부 처리 필요
SUCCEEDED -> 발송 완료
CANCELLED -> 중지됨
FAILED -> 발송 실패6.5 Cross-system consistency
PostgreSQL and Sign do not share a transaction. Use a request-key saga:
- In a local transaction, DHub claims the first-notice processing stage as
PROCESSING, creates/loads the current Sign attempt and itsESignRecordwith stable request key, selected template, and immutableresponseDurationSecondsSnapshot, and leaves remote contract/recipient fields null until S1 succeeds. - DHub calls S1 create-and-send with the selected template snapshot,
responseDurationSeconds, and no native recipient notification for the required Messenger path. Duplicate request keys return the original result. - Sign returns
contractId,recipientReference,sentAt,provisionalExpiresAt, status, and template fingerprint. DHub validates/reconciles the sameESignRecordand transactionally creates a workflow message inPENDING_PUBLICATION; pending messages are not queryable by the employee and do not affect unread/preview. Final businessexpiresAtremains null. - While holding the promotion/attempt/message claim, the worker chooses
plannedPublicationAt = now, locally derives the expected deadline from the immutable duration snapshot, incrementsdeadlineRevision, and calls idempotent S5 with an operation key derived from attempt + revision. Sign loads the S1 duration snapshot, independently computes finalexpiresAt = plannedPublicationAt + responseDurationSecondsSnapshot, retainsprovisionalExpiresAtfor preparation audit, and rejects older revisions/deadline reduction. - After S5 returns the exact expected
expiresAt, DHub transactionally changes the prepared message toPUBLISHED, writes attemptmessagePublishedAt = plannedPublicationAt, updates unread/preview, stores the same attempt/e-sign deadline, transitions the attempt/aggregate toAWAITING_SIGNATURE, appends timeline, and inserts the notification Outbox row.messagePublishedAtis never written before this commit. - If the publication transaction fails, the message remains hidden and
messagePublishedAtremains null. Retry locks the same case/attempt/message, chooses a new laterplannedPublicationAt, increments the revision, extends Sign expiry through a new deterministic S5 revision key, and attempts publication again. It never exposes a notice whose response window is shortened. - An Outbox worker emits realtime/push/browser notification after commit with a stable event key. Notification delivery failure does not revert the Sign request or duplicate the Messenger message.
If DHub crashes after Sign succeeds, retry retrieves the same remote contract. If the message cannot be published after exhausted retries, keep the case failed/reviewable and cancel the inaccessible remote request through the approved compensation path; never leave an indefinitely active orphan. A local pre-create check is not sufficient.
Second designation has no external Sign write. Plan items, timeline, workflow message, and state transition commit in one DHub transaction.
6.6 Completion and repair
- Sign webhook is the primary signal and includes
eventId,eventType,occurredAt, monotonicstatusVersion,contractId,requestKey, stableexternalOrganizationId, status, and terminal timestamps.signCorpId/apiCredentialIdmay remain diagnostic metadata, but they are not DHub's tenant lookup source of truth. - Headers include a delivery timestamp and HMAC signature. DHub rejects invalid signatures and timestamps outside the replay window.
- Sign validates
externalOrganizationIdagainst the authenticated DHub corporation/API-credential mapping and persists it on the external request. DHub uses that organization scope to load the active integration secret, verifies the signature before mutation, resolves the pre-created record byorganizationId + externalRequestKey, conditionally binds the webhookcontractId, inserts a uniqueeventIdwebhook Inbox row, and only then acknowledges delivery. - Inbox processing is retryable. Duplicate events are no-ops; older/equal
statusVersioncannot regress state; the raw payload is access-controlled and retained only according to the approved audit policy. - DHub fetches authenticated, allowlisted result fields from Sign.
- Promotion becomes
APPLIEDafter the required employee signature is authenticated and confirmed completed. No employee plan fields participate in completion. - A periodic reconciler repairs missed webhooks.
- Expiry polls Sign immediately before cancellation.
- Sign cancellation atomically returns
cancelled,already_cancelled,completed, ornot_cancellable.
7. Required hereby-sign API Contract
P1 cannot begin until these contracts and tests exist. Existing corporation-scoped template listing remains the discovery API; S1/S5/S6 extend the selected-template flow rather than adding an organization template mapping.
S1. Create and send from template
Input:
requestKey
externalOrganizationId
templateId + expected version/fingerprint
contractName
creatorName / creatorEmail
recipientName
recipientEmail? / recipientPhone?
fieldValues
notificationPolicy
responseDurationSeconds
Output:
contractId
recipientReference
status
sentAt
provisionalExpiresAt
templateVersion/fingerprint- Request key is stable for the DHub operation and returns the original result on retry. Sign verifies
externalOrganizationIdagainst the authenticated corporation/API credential, snapshots it with the request, and rejects a retry that supplies a different organization. - Endpoint finalizes and sends; an editor draft is not success.
recipientReferenceis an opaque, non-bearer identifier usable only through authenticated DHub-to-Sign APIs; it is never a Holder login/admin dashboard URL.- Recipient email is optional. If Sign needs an internal identity, Sign creates/hides it; DHub does not fabricate email.
- Name-only access remains possible after DHub issues a short-lived signer session through S6.
- Sign validates
responseDurationSecondsas an integer from 60 through 31,536,000 seconds (1 minute–365 days), stores it immutably on the external request, and computesprovisionalExpiresAt = sentAt + responseDurationSeconds. DHub uses the same shared constants. The provisional value is never serialized or persisted as final businessexpiresAt; S5 establishes finalexpiresAtbefore signer access. - Validate template ownership, corporation, the employee-only signer role, consent-document compatibility, and version before creation. Do not require employee-editable leave-plan labels.
- Notification policy from D6 is explicit.
- P1 uses this one-contract endpoint from the DHub batch worker; Sign does not need a separate native bulk endpoint.
notificationPolicy = noneis mandatory for P1 because the required delivery channel is DHub Messenger. Any additional Sign-native SMS/email requires a later ticket.- A returned draft,
/editor/..., contract dashboard, Holder login URL, reusable bearer URL, missingsentAt/provisionalExpiresAt, or an S1 field named finalexpiresAtis failure; only a formally sent contract plus employee recipient reference and explicitly provisional preparation deadline is success.
S2. Cancel external contract
Input: contractId, requestKey, reasonCode, reasonText
Output: outcome, status, terminalAtVerify API ownership and atomically refuse cancellation when completed. Notification failure must not roll back cancellation.
S3. Read completed result
Return only the allowlisted consent evidence needed by DHub, signer evidence, and completion time. Employee leave-plan fields are not part of the contract. Never expose another tenant or the entire raw editor document.
S4. Extend status/webhook
- Payload includes
eventId,eventType,occurredAt, monotonicstatusVersion,contractId,requestKey, stableexternalOrganizationId, status, and clearsentAt, optionalprovisionalExpiresAt, optional finalexpiresAt,completedAt, andcancelledAt/terminalAt.signCorpIdandapiCredentialIdmay be included only as diagnostics. Consumers must never substitute the provisional field for the final one. - Sign sends timestamp + HMAC headers, retries until DHub durably acknowledges, and preserves the same
eventIdacross delivery retries. - Use
계약 취소, not계약 거절, for cancellation. - Status endpoint and webhook must represent the same state.
S5. Synchronize recipient deadline
Input: contractId, recipientReference, operationKey, deadlineRevision, plannedPublicationAt
Output: contractId, deadlineRevision, expiresAt, status, statusVersion- Verify credential/corporation/contract/recipient ownership. Load the immutable
responseDurationSecondsSnapshotstored by S1, validateplannedPublicationAtas a UTC instant within ±5 minutes of Sign server time, and computeexpiresAt = plannedPublicationAt + responseDurationSecondsSnapshot; do not accept a caller-supplied final deadline or duration override. - Idempotently return the same result for the same operation key/revision. Before publication/session issuance, a later publication attempt uses a higher revision and deterministic new key; Sign rejects older revisions and any deadline reduction.
- The remote request state is
PENDING_PUBLICATIONuntil DHub publishes the notice. While it remains in that state, has issued no signer session/native notification, and has no completion/cancellation, S5 may extendprovisionalExpiresAteven when wall-clock time has already passed it. - S5 and the Sign preparation-expiry worker lock/CAS the same request row. If S5 wins, it stores the higher revision/deadline. If the expiry worker first commits terminal
EXPIRED/CANCELLED, S5 returns a typed terminal outcome and must not revive the request. - DHub maps a confirmed preparation-expiry/cancellation to
FAILEDwith audited compensation and permits only a manually initiated new request/key. Unknown or contradictory remote terminal truth maps toREVIEW_REQUIRED; automatic replacement is forbidden. - Sign and DHub both define
now >= expiresAtas expired. Status, cancellation, signing submission, and S6 session issuance use the same boundary. - DHub locally computes the same expected value and rejects any mismatch. It cannot publish the prepared Messenger message or transition to
AWAITING_SIGNATUREuntil Sign acknowledges it; after acknowledgement, the publication transaction writesmessagePublishedAt = plannedPublicationAtexactly.
S6. Issue recipient session
Input: contractId, recipientReference, requestKey
Output: sessionUrl, expiresAt- Require the authenticated API credential/corporation to own the contract and recipient reference.
- Issue a signer/contract-bound, revocable URL valid for exactly 10 minutes; never return a Holder/admin token.
- Refuse issuance when
now >= contract.expiresAt; an already issued session also becomes unusable at the earlier of its own 10-minute expiry or contract expiry. - Refuse issuance for completed, cancelled, expired, mismatched, or non-signable contracts.
- Reusing a DHub operation request key may return the same still-valid session, but a session URL is never stored in DHub or exposed through list/detail/message APIs.
Expected Sign files
apps/contract-server/src/app/contract/contract.external.controller.ts
apps/contract-server/src/app/contract/dto/contract.external.dto.ts
apps/contract-server/src/app/contract/contract.service.ts
apps/contract-server/src/app/contract/contract.external-status.ts
apps/contract-server/src/app/contract/external-signer-auth.ts
apps/contract-server/src/app/contract/contract-preparation-expiry.service.ts
apps/contract-server/src/app/scheduler/scheduler.module.ts
apps/contract-server/src/app/scheduler/scheduler.service.ts
apps/contract-server/src/app/notification/notification-contract-link.ts
apps/contract-server/src/database/entities/apiContractRecords.entity.ts
apps/contract-server/src/database/entities/contract.entity.ts
apps/contract-server/src/database/query/api.query.ts
apps/contract-server/src/database/query/contract.query.ts
apps/contract-server/src/database/migrations/<timestamp>-ExternalContractRequestKey.ts
apps/contract-server/src/database/migrations/<timestamp>-ExternalContractPublicationState.ts
apps/contract-server/src/app/contract/__tests__/contract.external.service.spec.ts
apps/contract-server/src/app/contract/__tests__/contract.external.controller.spec.ts
apps/contract-server/src/app/contract/__tests__/contract-preparation-expiry.service.spec.tsImplement against the current develop baselines recorded in section 3.3. Recheck repository status before each slice, preserve unrelated user changes, and do not recreate the already-merged Holder/provisioning chain.
8. Delivery Tasks
Task 0 — Verify frozen decisions, baseline, and fixtures
Changes: Decision note only; no production code.
- [ ] Treat D1–D12 and section 1.1 items 1–20 as frozen; record any code-proven impossibility before changing behavior.
- [ ] Use the exact section 5.1 Korean templates/variables and platform fallback/help text from D4.
- [ ] Verify current
developbaselines and the existing organization credential -> Sign corporation -> corporation-scoped template-list/create validation chain. Do not rebuild Holder or add a second organization-template mapping. - [ ] Define the runtime-selectable P1 template compatibility contract: version/fingerprint, employee-only signer role, and consent-document compatibility. Reject company-side signer templates and templates that require employee leave-plan entry. Freeze one non-production consent fixture template for tests only.
- [ ] Freeze the fixture's server-prefilled label-to-source/value mapping for employee identity, period start/end/
periodKey, remaining balance, and notice/deadline values. Record exact field IDs/labels/types used for S1fieldValues; S3 validates signer/completion evidence, not an employee plan. - [ ] Record sanitized Sign create/send/deadline/session/status/result/cancel fixtures from local tests or an already-authorized non-production environment.
- [ ] Record non-production Sign corporation/template/webhook prerequisites as rollout configuration; absence does not block local implementation/build/tests.
Gate: S1–S6 request/response/status fixtures must be captured before DHub integration code depends on them; no product decision remains open.
Task 1 — Deliver TC20 across every workspace creation path
DHub files:
packages/core/src/schemas/organization.schema.ts
packages/database/src/entities/organization.entity.ts
packages/api/src/services/workspace-provisioning.service.ts
packages/api/src/routers/organization.router.ts
tools/system-admin/server/routes/provision.ts
apps/admin/src/app.ts
apps/admin/src/views/tabs/settings.html
co-located tests- [ ] Add independent
settings.messageSender.{name,email}; never reusesettings.sign. - [ ] Use one shared sender schema with trimmed name and valid optional email.
- [ ] Initialize owner values in signup workspace provisioning and system-admin provisioning.
- [ ] Audit the legacy public organization-create path: initialize it correctly if active; if unreachable, document it without removing/deprecating it in this scope.
- [ ] Existing organizations receive no backfill. Missing settings use approved platform fallback.
- [ ] Add read/edit
메시지 발신cards matching the e-sign card's field/help/free-input UX. - [ ] Save through
Resource.SETTING / Action.UPDATE; no employee picker. - [ ] Never synchronize later owner changes.
- [ ] Test both active creation paths, save/reload, blanks, invalid email, owner change, and tenant isolation.
TC20 done when: All four ticket acceptance checks pass with exact empty-state/help copy.
Task 2 — Add official workflow conversations/messages
DHub files:
packages/core/src/enums/communication.enum.ts
packages/core/src/schemas/message.schema.ts
packages/database/src/entities/conversation.entity.ts
packages/database/src/entities/message.entity.ts
packages/database/src/entities/outbox-event.entity.ts
packages/database/src/migrations/<timestamp>-AddWorkflowMessages.ts
packages/api/src/services/workflow-message.service.ts
packages/api/src/jobs/outbox.job.ts
packages/api/src/routers/conversation.router.ts
packages/api/src/routers/message.router.ts
packages/api/src/events/message-events.ts
packages/components/src/components/Messenger.ts
packages/components/src/components/messenger-helpers.ts
packages/components/html/messenger-content.html
co-located tests- [ ] Add
WORKFLOWenum and database enum migration. - [ ] Add workflow recipient identity and partial unique constraint for one active room per organization/employee.
- [ ] Add message origin/purpose, sender snapshots, structured action, reference, operation key, and publication state
PENDING_PUBLICATION | PUBLISHED. Purpose enum/schema/database values are exactlyannual_leave_promotion_first | annual_leave_promotion_reminder | annual_leave_promotion_second. - [ ] Implement one server-side workflow-message service; client routes cannot choose display identity.
- [ ] Support preparing a hidden workflow message without preview/unread/realtime effects, reserve
plannedPublicationAtoutside persisted message publication state, then atomically publish it withmessagePublishedAt = plannedPublicationAt, preview/unread update, and one stable-key Outbox event only after Sign acknowledges the exact deadline. - [ ] Emit realtime/conversation update/push from the Outbox only after commit and with display snapshots. Retry delivery without duplicating the persisted message.
- [ ] Track
pushDeliveredAtwhen supported andreadAtfrom Messenger read state; neither changesdeadlineAtor business success. - [ ] Show workflow name, email, and CTA; keep ordinary DM rendering unchanged.
- [ ] Deny reply/leave/rename/member management in API and hide controls in UI.
- [ ] Test all three purposes serialize/render/search independently; reminder reuses the original immutable attempt CTA reference without being mistaken for a first notice, and an old-attempt CTA cannot redirect to a new reissue. Also test pending invisibility, publication idempotency, snapshot history, fallback, unread/realtime, room uniqueness, no-reply, and ordinary-DM regression.
Gate: TC20 is not done merely because the settings card exists; a real official message must render both configured fields.
Task 3 — Add promotion domain and state machine
DHub files:
packages/core/src/enums/annual-leave-promotion.enum.ts
packages/core/src/schemas/annual-leave-promotion.schema.ts
packages/core/src/enums/index.ts
packages/core/src/schemas/index.ts
packages/database/src/entities/annual-leave-promotion.entity.ts
packages/database/src/entities/annual-leave-promotion-sign-attempt.entity.ts
packages/database/src/entities/annual-leave-promotion-batch.entity.ts
packages/database/src/entities/annual-leave-promotion-batch-item.entity.ts
packages/database/src/entities/annual-leave-promotion-plan-item.entity.ts
packages/database/src/entities/annual-leave-promotion-event.entity.ts
packages/database/src/entities/index.ts
packages/database/src/migrations/<timestamp>-AddAnnualLeavePromotion.ts
packages/api/src/services/annual-leave-promotion-state.service.ts
co-located tests- [ ] Put enums/schemas in core and use
z.nativeEnum(...). - [ ] Add organization-scoped aggregate, immutable Sign attempts, company second-designation schedule items, and append-only events. Do not use plan items for employee Sign input.
- [ ] Add organization-scoped batch/item entities, cancel-request fields/counters, stable request/operation keys, claims, retry metadata, and immutable submission snapshots.
- [ ] Use
periodKeyand period snapshots, not calendar year alone. - [ ] Add unique case, batch item, request/operation key, batch claim/retry, deadline scan, employee lookup, and e-sign reference indexes.
- [ ] Promotion owns nullable
currentSignAttemptId; every attempt owns one uniqueeSignRecordId.ESignRecordexclusively owns external contract/request/recipient reference, template snapshot, remote state/version, and Sign timestamps; promotion/attempt must not duplicate them. - [ ] Add attempt-number/request/e-sign uniqueness plus organization-scoped external contract/request constraints, and enforce same-organization assignment across promotion -> attempt -> e-sign.
- [ ] Separate business state from processing stage/status. Include
RETRY_WAIT, exhaustedFAILED, and evidence-preservingREVIEW_REQUIRED; do not let a failed attempt permanently lock the employee or create a blind replacement contract. - [ ] Encode all transitions in one state service; routers/jobs/webhooks cannot write status directly. Per-attempt terminal state is monotonic; only explicit reissue success may move aggregate
EXPIRED_CANCELLED -> AWAITING_SIGNATUREwith a new current attempt. - [ ] Add Korean processing/error and ticket status labels, including
EXPIRED_CANCELLED -> 요청 취소(기한 초과); exposecanSecondDesignateseparately from business status. - [ ] Test unique period, attempt numbering/current pointer, old-attempt immutability, same-key recoverable retry, new-key terminal reissue, duplicate batch submission/item, valid/invalid business and processing transitions, terminal idempotency, counter/cancellation reconciliation, review-required reissue denial, e-sign SoT constraints, soft delete, tenant scope, and event order.
Task 4 — Implement and verify S1–S6 in hereby-sign
Repository: /Users/joshua/Project/FIG/hereby/hereby-sign
- [ ] After Eron explicitly authorizes Patch 7 code changes, implement the corrected S1–S6 contract while reusing the existing corporation-scoped template listing and ownership checks.
- [ ] Add request-key uniqueness migration and ownership checks.
- [ ] Add explicit remote publication state,
provisionalExpiresAt, nullable finalexpiresAt, deadline revision, and terminal preparation outcome fields/migrations. Never overload one column/DTO field for both deadlines. - [ ] Implement
ContractPreparationExpiryServiceand register it through the existing Nest scheduler. It and S5 use the same row lock/CAS query: pending S5 revision may win after wall-clockprovisionalExpiresAt, but a committed terminal expiry/cancellation can never be revived. - [ ] Test name-only, email/phone variants, non-bearer recipient reference, exactly-10-minute revocable session,
now >= expiresAtacross status/session/submission/cancel, S1 duration bounds/snapshot immutability, S5 ±5-minute planned-publication validation and server-side deadline computation, duplicate request key, S5 extension after provisional time but before terminal CAS, expiry-worker-wins terminal refusal, no terminal revival, idempotent deadline revision, template mismatch, cross-corporation denial, result allowlist, cancellation race, already cancelled, completed-before-cancel, and callback/status parity. - [ ] Implement timestamped-HMAC webhook events with stable
eventId, monotonicstatusVersion, tenant identity, durable-delivery retry, and synchronized send/expiry timestamps. - [ ] Run targeted tests/build.
- [ ] Capture verified local/test fixtures. Non-production deployment and live verification occur only after separate deployment approval.
Gate: A returned /editor/... or Holder-token URL fails this task.
Task 5 — Update DHub Sign adapter and e-sign assumptions
DHub files:
packages/api/src/services/hereby-contract.service.ts
packages/api/src/routers/e-sign.state.ts
packages/api/src/routers/e-sign.router.ts
packages/database/src/entities/e-sign-record.entity.ts
packages/database/src/entities/sign-webhook-inbox.entity.ts
packages/database/src/migrations/<timestamp>-ExtendESignAndWebhookInbox.ts
packages/core/src/schemas/e-sign.schema.ts
packages/sdk/src/services/ESignService.ts
apps/admin/src/views/tabs/e-sign.html
co-located tests- [ ] Add typed adapter methods for template list/validation, create-and-send, synchronize-recipient-deadline, issue-recipient-session, status, result, and cancel.
- [ ] Keep the existing active organization credential/corporation mapping as the only template visibility boundary. Never accept an arbitrary template outside the server-returned organization list.
- [ ] Make recipient email snapshot nullable; keep name/phone snapshots.
- [ ] Update e-sign list/search for name-only/phone-only recipients without fake email.
- [ ] Make
ESignRecordthe sole Sign integration SoT for contract ID, request key, recipient reference, template snapshot, remote state/version, immutable response-duration snapshot,provisionalExpiresAt, nullable finalexpiresAt, and other Sign timestamps. API/UI business deadline reads only finalexpiresAt. - [ ] Resolve promotion integration through
ESignRecord -> AnnualLeavePromotionSignAttempt -> AnnualLeavePromotion; current and historical attempts remain tenant-scoped and queryable without overwriting old e-sign rows. - [ ] Add a scoped open-signer procedure keyed by immutable attempt reference. Verify current employee/organization, referenced attempt equals the promotion's current signable attempt, published-message/business/processing state, and
now < attempt.deadlineAt; then call S6, validate HTTPS/configured Sign origin, and redirect without persisting the session URL. Never retarget an old message to a newer attempt. - [ ] Never persist or expose a long-lived recipient/session URL in list/detail payloads, logs, realtime events, push data, or Messenger.
- [ ] Keep Holder and
settings.signseparate fromsettings.messageSender. - [ ] Map cancellation accurately instead of employee rejection.
- [ ] Require active organization Sign credentials and Korean 해요체 errors.
- [ ] Never persist/log Holder login tokens as employee links.
- [ ] Add a durable webhook Inbox keyed by
eventId; verify HMAC/replay window and tenant mapping before insert/ack, process monotonically bystatusVersion, and retain retry/error metadata. - [ ] Test response validation, template tenant/version validation, URL origin, unauthorized employee access, expired/cancelled/completed session denial, nullable email, remote errors, event deduplication/reordering/replay, cross-tenant callback, and status mapping.
Task 6 — Build first-notice API saga
DHub files:
packages/api/src/services/annual-leave-promotion.service.ts
packages/api/src/routers/annual-leave-promotion.router.ts
packages/api/src/router.ts
packages/sdk/src/services/AnnualLeavePromotionService.ts
packages/sdk/src/client/HerebyClient.ts
co-located tests- [ ] Use
scopedProcedure(Resource.LEAVE, Action.UPDATE)andassertInScope. - [ ] Recompute active employee, selected period, remaining balance, existing case, and Sign readiness on server.
- [ ] In one pre-call transaction, claim first-notice processing and create/load the current
AnnualLeavePromotionSignAttemptplus itsESignRecordwith stable external request key, selected template, and duration snapshot. Keep nullable remote fields empty until S1 succeeds; business state and processing state remain separate. - [ ] Require admin to select one currently corporation-visible, P1-compatible Sign template for the individual send. Pass its expected version/fingerprint and
responseDurationSecondsto S1. - [ ] Call S1 and validate contract ID, sent status, recipient reference,
sentAt/provisionalExpiresAt, corporation ownership, and template snapshot. Reject drafts, admin URLs, reusable bearer URLs, mismatched templates, or a response that incorrectly supplies final businessexpiresAtbefore S5. - [ ] Reconcile the pre-created
ESignRecordand create the first-notice workflow message asPENDING_PUBLICATION; it has internal promotion + immutable attempt references and서명하기CTA but is invisible to the employee and excluded from unread/preview/search. - [ ] Reserve
plannedPublicationAt, locally compute the expected deadline from the immutable S1 duration snapshot, and call S5 without caller-suppliedexpiresAt. Only after Sign returns the exact expected value, atomically publish with attemptmessagePublishedAt = plannedPublicationAt, persist the same attempt/e-sign deadline, update unread/preview, transition attempt/aggregate toAWAITING_SIGNATURE, append events, and insert the notification Outbox event. - [ ] If publication commit fails, keep the message hidden with null
messagePublishedAt, choose a laterplannedPublicationAt, extend the Sign deadline through a higher idempotent S5 revision, and retry. Exhausted failure triggers audited remote-cancel compensation; it never exposes a shortened response window or orphan active request. - [ ] Emit realtime/push/browser notification through the Outbox only after commit; delivery/read timestamps do not alter deadline or business success.
- [ ] On retry after uncertain remote outcome, resolve the same Sign request key from
ESignRecord.externalRequestKey, recover any existing contract before creating, and create only after Sign confirms absence. - [ ] After exhausted retries, expose
FAILED; route inconsistent completed/result/template evidence toREVIEW_REQUIREDwith audited manual handling. - [ ] Implement D2 reminder using
annual-leave-promotion-reminder-v1exactly as section 5.1. Allow only signableAWAITING_SIGNATUREwithnow < expiresAt; reuse promotion/e-sign/recipient/CTA/deadline, snapshot current display sender/executor, append an audit event, and use a stable reminder operation key. Never call S1/S5, create a contract, or reset the deadline. - [ ] Add explicit
다시 발송only for confirmed terminalEXPIRED_CANCELLEDand no active/review processing. Admin reconfirms template/duration; transaction creates a new attempt number,ESignRecord, request key, operation key, and hidden first notice while retaining old attempts. Aggregate stays cancelled until new publication succeeds, then returns toAWAITING_SIGNATURE;REVIEW_REQUIREDcannot reissue. - [ ] Duplicate submissions return existing operation result.
- [ ] Expose this as one idempotent employee-level service operation used by both row send and the batch worker; do not duplicate the saga in a batch router/job.
- [ ] Do not keep an HTTP request open while multiple employees are processed and do not open a Sign editor/dashboard from this flow.
- [ ] Test Sign failure, remote success/local failure/retry, hidden pending message, provisional-vs-final deadline serialization, planned-vs-committed publication timestamp, S5 duration snapshot computation, deadline-sync failure, publication-commit retry with deadline extension, exhausted cancel compensation, exact
messagePublishedAt + durationequality, reminder copy/CTA/idempotency/no-deadline-reset/no-new-contract, reminder denial after expiry/terminal state, terminal reissue creates new attempt/e-sign/key and preserves old records, review-required reissue denial, uncertain remote recovery, exhausted failure/manual retry, double click, duplicate key, stale employee/balance/period, tenant/scope denial, template mismatch, and fallback sender.
Acceptance: Messenger CTA and e-sign record exist; status waits; notice alone never applies promotion.
Task 7 — Add manual batch first-notice orchestration
DHub files:
packages/core/src/schemas/annual-leave-promotion.schema.ts
packages/database/src/entities/annual-leave-promotion-batch.entity.ts
packages/database/src/entities/annual-leave-promotion-batch-item.entity.ts
packages/database/src/entities/annual-leave-promotion-selection-snapshot.entity.ts
packages/api/src/services/annual-leave-promotion-batch.service.ts
packages/api/src/jobs/annual-leave-promotion-materialize.job.ts
packages/api/src/jobs/annual-leave-promotion-selection-cleanup.job.ts
packages/api/src/jobs/annual-leave-promotion-batch.job.ts
packages/api/src/routers/annual-leave-promotion.router.ts
packages/api/src/index.ts
packages/sdk/src/services/AnnualLeavePromotionService.ts
co-located tests- [ ] Add preflight for
EXPLICIT_IDSand deliberateALL_ELIGIBLE_IN_SCOPE. Resolve scope on the server, persist a 15-minute organization/actor/period/filter-bound selection snapshot with exact IDs/hash and prioritized skip reasons, and return only an opaque token plus exact totals; no promotion/notice state is created. - [ ] Return the existing organization credential's corporation-scoped P1-compatible template options in preflight. Require one explicit template selection; never persist it as an organization default.
- [ ] Add idempotent submit with
clientRequestKeyand the preflight token. Atomically lock/validate/consume the exact snapshot and create oneMATERIALIZINGparent batch, then returnbatchId; duplicate submit returns the same batch. - [ ] Add a durable materializer that creates one item per snapshotted employee in idempotent chunks of 100, resumes after restart from item truth/cursor, moves a fully materialized active batch to
QUEUED, and onCANCEL_REQUESTEDmaterializes all remaining employees directly asCANCELLED; headcount over 100 never requires another admin submission. - [ ] After successful materialization, redact the snapshot's duplicated employee-ID array while retaining hash/count/batch reference. Redact and soft-delete expired unconsumed snapshots after 24 hours; never hard-delete audit records.
- [ ] Revalidate organization, permission scope, employee status, selected period, remaining balance, active promotion, template readiness, and integration readiness inside the worker before each item.
- [ ] Snapshot executor, display sender, copy version, Sign template ID/name/version/fingerprint/roles/required labels, and frozen D11 deadline duration at submission.
- [ ] Claim items with database-safe locking such as
FOR UPDATE SKIP LOCKED; recover stale claims after process death without duplicating work. - [ ] Enforce per-organization concurrency 3 and global concurrency 10, exponential backoff/maximum attempts, and
Retry-Afterfor Sign429; keep values configuration-backed with these frozen defaults. Never log recipient URLs or secrets. - [ ] Invoke Task 6's single-item saga with each item's stable operation/request keys.
- [ ] Mark ineligible stale items
SKIPPED; useRETRY_WAITfor scheduled retry, exhausted/non-retryableFAILED, andREVIEW_REQUIREDfor evidence contradictions. Retry on the same item/key. - [ ] Reconcile batch counters/status from durable materialization/item truth; support every parent state
MATERIALIZING,QUEUED,RUNNING,CANCEL_REQUESTED,COMPLETED_WITH_ISSUES,SUCCEEDED,CANCELLED, andFAILEDwithout cross-item rollback. - [ ] Add an audited
남은 발송 중지procedure. It prevents new claims, marks only unstarted queued itemsCANCELLED, lets in-flight items settle, and never automatically cancels already-sent Sign contracts. - [ ] Add scoped batch status/detail and failed-item retry procedures. Recoverable retry reuses the same attempt/key/deadline and cannot include succeeded/skipped items. A confirmed terminal item is not a retry: it requires explicit employee-level
다시 발송, creates a new attempt/key/deadline, and remains outside automatic failed-only retry. - [ ] Register only an execution worker for submitted batches. Do not register target discovery or daily automatic first-notice creation.
- [ ] Test explicit IDs, all-eligible selection beyond one page/100 employees, opaque token contains no IDs, snapshot expiry/actor/tenant/filter mismatch, single consumption/duplicate submit, materializer crash/resume/duplicate chunk/stop, per-org/global concurrency, partial failure, Sign 429
Retry-After/timeout, worker restart/stale claim, permission or employee change after preflight, setting/template change during execution, retry-only-failed, stop-before-start/during-flight/after-send, batch counter repair, tenant isolation, and no browser dependency.
Acceptance: One admin batch action can complete independent Sign send + Messenger notice flows for every eligible employee; partial failures are visible and retryable without duplicate contracts/messages.
Task 8 — Add annual-leave status UI, individual/batch first notice, and timeline
DHub files:
packages/api/src/routers/leave-request.router.ts
apps/admin/src/attendance/annual-leave-promotion.ts
apps/admin/src/attendance/annual-leave-promotion.test.ts
apps/admin/src/app.ts
apps/admin/src/views/tabs/leave-management.html
apps/admin/src/views/modals/annual-leave-promotion.html
apps/admin/src/views/modals/annual-leave-promotion-batch.html
apps/admin/src/views/dashboard.html- [ ] Return server-resolved
periodKeyand batch promotion summary with balance rows; preserve scope and batch-query behavior. - [ ] Add row checkboxes, an explicit
전체 대상 선택control, promotion status/filter, and one state-driven row action to existing Table 1 without turning it into a schedule grid. - [ ] Keep existing
상세/조정behavior. Add promotion status and a state-driven개별 통지/촉진 현황/완료 내역/요청 취소(기한 초과)/다시 발송/사용시기 지정/지정 내역action without showing every unavailable action at once.다시 발송is shown only when the server permits it for the currentEXPIRED_CANCELLEDcase. - [ ] Add
일괄 통지near the annual-leave controls and발송 내역beside it. Distinguish선택한 직원from deliberate전체 대상; show exact server-preflight scope/count. Changing year/filter, token expiry, or leaving the page must not silently retarget the snapshot. - [ ] Build a standard batch confirmation modal that requires one corporation-scoped compatible Sign template and shows selected, eligible, skipped, sender snapshot, copy/template name+version, response duration, and exact skip reasons before submit.
- [ ] Build a standard panel modal for batch progress/history with the fixed parent-state label/banner (
발송 대상 생성 중,발송 대기,발송 중,남은 발송 중지 중,일부 처리 필요,발송 완료,중지됨,발송 실패) independently of queued/processing/retry/succeeded/skipped/failed/cancelled/review-required item counts. Include per-employee state/error, refresh/polling,실패 건 재시도, and남은 발송 중지while stoppable. - [ ] Put pure status/eligibility/format helpers in the attendance module, not only the 11k-line
app.ts. - [ ] Build one standard
.ds-modalwith employee summary, balance, compatible Sign template selection, copy preview, response duration/deadline explanation, sender display, timeline, close, and send. - [ ] In
AWAITING_SIGNATURE, show재안내only when the server says reminder is allowed. Its confirmation previews the frozen reminder copy/deadline; expired, terminal, processing-failed, or review-required cases never show an enabled reminder action. - [ ] No sender picker and no editable copy; use frozen section 5.1 templates.
- [ ] Use standard buttons/controls/labels/tooltips/notices; no native dialogs or new mini-modal.
- [ ] Show business labels
안내 전,서명 대기,제도 적용,요청 취소(기한 초과),2차 지정 완료separately from derived지정 필요, processing labels대기,처리 중,재시도 대기,처리 실패,검토 필요, and parent batch labels. Never map processing/failure/action availability to a false business-complete status. - [ ] An item displays
서명 대기only after the complete Task 6 success boundary. Show processing/retry state for partial saga progress. - [ ] Register stable
data-testidvalues. - [ ] Test selected year/period, selection reset semantics, preflight changes, every parent batch label including materializing/stopping/completed-with-issues, independent item counters, explicit cancelled-state vs derived designation action, reissue action/state, batch modal/progress/history, partial failure/retry, row action mapping, stale refresh, double-submit, Korean errors, status chips, and no balance mutation.
Task 9 — Process completion and repair missed webhooks
DHub files:
packages/api/src/routes/hereby-contract-webhook.routes.ts
packages/api/src/services/annual-leave-promotion.service.ts
packages/api/src/jobs/sign-webhook-inbox.job.ts
packages/api/src/jobs/annual-leave-promotion-reconcile.job.ts
packages/api/src/index.ts
co-located tests- [ ] Verify timestamped HMAC and replay window, resolve credential/corporation to one organization, durably insert the unique
eventIdInbox row, then acknowledge. Do not perform the full business transition in the HTTP callback. - [ ] Inbox worker applies only a newer
statusVersion, retains retry/failure metadata, and updates generic e-sign truth before promotion processing. - [ ] Resolve
ESignRecordthrough organization-scoped contract/request identity, then its immutable Sign attempt, then promotion; never resolve promotion by contract ID alone. Ignore historical-attempt events for current-state regression while preserving their e-sign/timeline truth. - [ ] Lock the attempt/case, verify it is the current attempt, keep business state stable, and set completion-sync processing to
PROCESSINGuntil result validation succeeds. - [ ] Fetch the completed result through the approved Sign result endpoint defined in S3 and validate the required signer/completion evidence. Do not fetch or normalize an employee leave plan.
- [ ] Transactionally save e-sign/attempt completion, event, and aggregate
APPLIEDstate without employee-plan persistence. - [ ] Duplicate/out-of-order webhook and duplicate result fetch are no-ops; contradictions route to
REVIEW_REQUIREDrather than silently applying invalid data. - [ ] Reconciler polls waiting/completion/cancellation repair states with bounded backoff.
- [ ] Assert no leave request/ledger/grant write.
- [ ] Test invalid HMAC, replay timestamp, lost/duplicate/out-of-order webhook, ack-after-Inbox durability, worker retry, invalid or delayed signature evidence, review-required contradiction, terminal conflict, cross-tenant contract/credential, signature-only completion, and no balance mutation.
Acceptance: Authenticated employee signature completion—not notice send—applies the promotion and exposes timeline/e-sign original without an employee plan.
Task 10 — Add deadline worker and atomic cancellation
DHub files:
packages/api/src/jobs/annual-leave-promotion-expiry.job.ts
packages/api/src/services/annual-leave-promotion.service.ts
packages/api/src/index.ts
co-located tests- [ ] Scan only current non-deleted attempts whose promotion is
AWAITING_SIGNATURE, wherenow >= attempt.deadlineAt, and no newer terminal processing result exists. Sign status/session/submission and DHub open-signer/worker tests use this identical boundary. - [ ] Run at an approved bounded interval (recommended at least hourly, preferably 10–15 minutes for compliance precision). This worker never selects employees or creates first notices/batches.
- [ ] Claim batches with a database-safe pattern such as
FOR UPDATE SKIP LOCKED; process-localrunningis not a multi-instance lock. - [ ] Set cancellation processing stage/status before external work without prematurely changing the business state.
- [ ] Poll Sign immediately before cancel; completed routes to Task 9.
- [ ] Call S2 with stable cancellation key and deadline-expired reason.
- [ ] Mark the current attempt and aggregate
EXPIRED_CANCELLEDonly for confirmedcancelled/already_cancelled; technical timeout/failure remains retryable and visible separately. UI renders요청 취소(기한 초과)and derivescanSecondDesignateseparately. - [ ] Retain pending/error metadata for timeout/failure; never cancel optimistically.
- [ ] Append detected/requested/succeeded/failed events.
- [ ] Test exact boundary, edited deadline, concurrent workers, repeated tick, timeout, already cancelled, completion race, restart, and tenant isolation.
Acceptance: Employee link cannot complete after confirmed cancellation; UI preserves 요청 취소(기한 초과) and independently exposes designation availability.
Task 11 — Build second designation and Messenger notice
DHub files:
packages/core/src/schemas/annual-leave-promotion.schema.ts
packages/api/src/routers/annual-leave-promotion.router.ts
packages/api/src/services/annual-leave-promotion.service.ts
packages/sdk/src/services/AnnualLeavePromotionService.ts
apps/admin/src/attendance/annual-leave-promotion.ts
apps/admin/src/app.ts
apps/admin/src/views/modals/annual-leave-promotion.html
co-located tests- [ ] Server returns
canSecondDesignate, one prioritized reason code, and the ticket's exact Korean tooltip text. - [ ] Priority: permission -> target employee -> remaining zero -> no first notice -> applied -> current attempt not
EXPIRED_CANCELLED/cancellation unconfirmed -> reissue/review processing active -> already designated. - [ ] Revalidate all seven ticket conditions on submit.
- [ ] Build the frozen D7 schedule editor. Use Table 2 only for a true schedule grid; otherwise use standard form rows/date pickers.
- [ ] Require the fixed section 5.1 reason copy and at least one valid schedule item.
- [ ] In one transaction save company plan, second workflow message with complete reason/schedule, events, and
SECOND_DESIGNATED. - [ ] Duplicate operation key returns existing result without another message.
- [ ] Do not call Sign or write leave request/ledger/grant.
- [ ] Exclude applied/second-designated/reissue-processing/review-required cases from
지정 필요while preserving explicitEXPIRED_CANCELLEDhistory. - [ ] Test every disabled reason/priority, stale state, invalid schedule, duplicate submit, rollback, zero balance, permission scope, and no balance mutation.
Task 12 — End-to-end verification and rollout
DHub files:
packages/test/e2e/annual-leave-promotion.spec.ts
packages/test/e2e/README.md
operational documentation/config only when proven necessary- [ ] E2E: owner-default setting -> select a corporation-scoped Sign template -> first notice -> Messenger name/email + CTA -> short-lived signer session -> verified Sign completion -> applied status/timeline/e-sign list.
- [ ] E2E: waiting case ->
재안내-> frozen reminder title/body + same CTA/contract/deadline -> duplicate click is idempotent; expired/terminal cases cannot remind. - [ ] E2E: explicit multi-row selection -> preflight -> manual batch submit -> independent Sign send + Messenger message per eligible employee -> batch success counts.
- [ ] E2E: deliberate
전체 대상 선택beyond one page/100 employees -> server snapshot token -> one parent batch -> internal 100-item chunks -> every eligible employee reaches independent terminal item truth without another admin submit. - [ ] E2E: batch partial failure -> successful items remain complete -> failed-only retry uses the same item/request keys -> no duplicate contract/message.
- [ ] E2E: admin leaves/reloads the page while a batch runs ->
발송 내역restores progress from persisted batch/item state. - [ ] E2E: admin selects
남은 발송 중지-> no new items start, queued items become cancelled, in-flight items settle, and already-sent contracts/messages remain unchanged. - [ ] E2E: parent batch progresses through visible
MATERIALIZING/QUEUED/RUNNING; stopping showsCANCEL_REQUESTED; mixed terminal items showCOMPLETED_WITH_ISSUESwith independent counters. - [ ] E2E: overdue case -> worker cancel -> explicit
요청 취소(기한 초과)+ derived지정 필요-> second designation -> reason/schedule message -> completed state. - [ ] E2E: confirmed cancelled attempt -> explicit
다시 발송-> new attempt/e-sign/request key/message/deadline -> old attempt remains visible; ordinary retry keeps the original key andREVIEW_REQUIREDcannot reissue. - [ ] Negative E2E/API: no timer or scheduler creates a first-notice batch without an explicit admin submit.
- [ ] Regression: ordinary DM keeps actual sender and reply behavior.
- [ ] Security: workflow room cannot reply/manage; cross-organization records/references are rejected; DHub stores no reusable signing URL; terminal/expired cases cannot issue signer sessions.
- [ ] Snapshot: setting change affects future messages only.
- [ ] Accounting: no path changes balance or creates leave request.
- [ ] Run migrations, targeted tests/builds, and repository gates:
pnpm lint:check
pnpm check:modals
pnpm check:dialogs
pnpm build- [ ] Run Sign contract-server tests/build and DHub E2E.
- [x] Run and record Sign migration
1786000000000-DHubExternalOrganizationReference. - [ ] Restart Sign and DHub before post-patch QA.
- [ ] Verify a fresh early
signer_waitingwebhook binds the pre-createdESignRecordbyexternalOrganizationId + requestKey, returns200, and produces one durable Inbox row. - [ ] Complete the webhook security matrix: cross-tenant organization/request substitution, conflicting existing contract ID, missing modern envelope fields, invalid signature, duplicate event, and older status version.
- [ ] Inspect the historical pre-
-117local orphan drafts and either retire them through the domain-safe path or document why they must remain; never resend contract81as cleanup. - [x] Correct Patch 5 reconciliation so only remote
in_progressrestores a waiting promotion to stable processingcompleted; all four result statuses have unit regression coverage. - [x] Patch 5 S5 success atomically persists the identical final deadline/revision to
ESignRecord, current attempt, promotion, Message publication, unread/timeline, and one Outbox row. - [x] Patch 5 first/reminder/second workflow titles persist, serialize through history/realtime, and render separately from ordinary Messenger conversation/body content.
- [x] Add Patch 5 workflow-title persistence/history/realtime mapping and publication idempotency/transaction-boundary regression tests.
- [ ] Add database-backed rollback and concurrent reconciliation tests.
- [x] At the pre-completion Patch 5 checkpoint, guardedly repair contract
81local truth without resending/cancelling/completing it; verify one contract, no additional ticket use,ESignRecord.expiresAt, stable processing status, delivered Outbox, and zero signer sessions before the employee click. - [x] Complete employee-side post-migration visual QA for
연차 사용촉진 안내and signer-session access. - [ ] Patch 7 removes the superseded Patch 6 completion-policy snapshot and employee-plan validation from DHub and Sign through a forward migration; do not edit the already-executed migration
1787000000000-DHubCompletionReliability. - [x] Implement Patch 6 atomic Sign contract/request/completion-Outbox commit and authenticated idempotent uncertain retry.
- [x] Implement Patch 6 durable timestamped-HMAC webhook Outbox worker with retry, delivery audit, and lease-based concurrency recovery.
- [x] Enforce
notificationPolicy=noneat DHub first notice, Sign DHub completion, and the DHub callback boundary; native lifecycle matrix still needs database-backed proof. - [x] Preserve contract
81unchanged as failed QA evidence; never fabricate its missing completion event or mark its DHub case applied. - [x] Execute Sign Patch 6 migration
1787000000000-DHubCompletionReliability; post-migration preview reports no pending migrations. - [ ] After Patch 7, run the remaining Patch 6/7 Sign/DHub regression matrices and repository gates, then execute a fresh signature-only completion E2E on a new designated QA case.
- [ ] Rollout gate (separate authorization): manually verify real non-production create/send/invite/complete/result/cancel and missed-webhook repair.
- [ ] Add metrics/alerts for queued/running/stale batches, item latency/success/skip/failure/retry, reminders, Sign rate limits, first-notice/preparation pending, preparation-expiry/S5 CAS outcomes, completion sync, cancellation pending, reconciliation, webhook, and result errors.
- [ ] Rollout gate (separate authorization): enable the organization feature flag only after staging and one pilot organization pass.
9. Failure-Recovery Matrix
| Failure point | Persisted truth | Retry behavior | Forbidden outcome |
|---|---|---|---|
| Batch accepted; API/materializer/worker restarts | Consumed server snapshot + parent batch + materialized/queued/claimed item truth | Resume idempotent 100-item materialization, then recover stale claims | Batch/selection exists only in browser memory or employees disappear between chunks |
| Duplicate batch submit | One batch owns clientRequestKey | Return original batch | Duplicate items/contracts/messages |
| Admin stops remaining batch | CANCEL_REQUESTED + audited actor/time/reason | Stop new claims; cancel queued; settle in-flight | Cancelling already-sent contracts or rewriting successes |
| Employee becomes ineligible after preflight | Item is skipped with reason | Admin may select again only after conditions change | Sending from stale client eligibility |
| Sign rate limit or one item fails | Other items retain independent truth; batch completes with issues | Bounded retry, then failed-only manual retry | Whole-batch rollback or retrying successes |
| Sign create/send fails or outcome is uncertain | Processing state + sanitized error; ESignRecord owns request key when known | Same request key first retrieves/reconciles remote truth; create only after confirmed absence | Blind replacement contract or permanent employee lock |
| Sign succeeds; message preparation/publication fails | One remote contract owns request key; prepared message stays hidden; deadline-sync state is durable | Retrieve the same contract, reserve a later publication time, extend expiry through S5, then publish atomically; cancel after exhausted compensation | Duplicate contract, visible broken notice, orphan active request, or shortened response window |
| Provisional deadline passes during pending publication | Sign row is still PENDING_PUBLICATION or already terminal by atomic expiry-worker truth | S5 higher revision may extend only if pending wins the lock/CAS; terminal result becomes failed/manual reissue, ambiguity becomes review-required | Reviving an expired/cancelled/completed contract |
| Employee signs a valid consent document with no leave-plan data | Sign contract/request complete atomically with signer evidence and one completion Outbox event | Return success; DHub applies after authenticated completion evidence | Requiring plan fields, rejecting completion, or routing to REVIEW_REQUIRED because the plan is empty |
| Sign completion commits; notification/webhook transport fails | Contract, external request, terminal timestamp, and one immutable webhook Outbox event committed atomically | Return committed success to the signer; retry the same event independently; authenticated uncertain retry returns idempotent success | Unknown-error response after commit, missing completion event, duplicate completion, or native notification failure controlling business truth |
| Confirmed terminal attempt is reissued | Old attempt/e-sign/message/event remain immutable; promotion points to a new preparing attempt | Explicit admin action creates new attempt/record/request key and new publication/deadline | Reusing terminal contract/key, overwriting history, or automatic reissue |
| Sender setting or selected Sign template changes mid-batch | Batch submission snapshots remain authoritative; worker rejects ownership/version/compatibility drift | New batch uses the new setting/template version | One batch silently mixing sender/template identity |
| Message commits; realtime/push fails | Messenger history/unread + Outbox committed | Refresh works; stable-key Outbox retries | Duplicate message or business rollback for push recovery |
| Signer CTA is copied or reused | DHub stores internal reference; Sign sessions are short-lived/revocable | Re-authenticate in DHub and issue a new session if still eligible | A durable bearer URL bypassing DHub authorization |
| Webhook arrives before create/send returns | Pre-created tenant-scoped ESignRecord.externalRequestKey; signed externalOrganizationId + requestKey + contractId event | Verify HMAC, conditionally bind the contract ID, durably insert Inbox, then ACK | 404 Record not found, unscoped lookup, unauthenticated binding, or duplicate contract retry |
| Webhook is duplicated/reordered/lost | Inbox dedupes eventId; ESignRecord.statusVersion is monotonic | Inbox retries; reconciler repairs loss | State regression, early ACK data loss, or cross-tenant update |
| Result temporarily unavailable or signer evidence is invalid | Processing RETRY_WAIT or REVIEW_REQUIRED; business truth unchanged | Backoff retry or audited manual review | Applied without validated signature/completion evidence |
| Expiry races completion | Sign cancel decides atomically | Completed -> completion; cancelled -> expiry | Both applied and expired |
| Cancel times out | Cancellation processing remains retryable; business stays awaiting | Stable key/status reconciliation | Second designation before confirmed cancel |
| Second designation transaction fails | No partial plan/message/state | Retry same operation | Designated without employee message |
10. Acceptance Traceability
| Ticket acceptance | Task | Required proof |
|---|---|---|
| TC20 card exists | 1 | Admin test + visual QA |
| New workspace defaults owner | 1 | Signup + system-admin provisioning tests |
| Name/email save | 1 | Schema/router/admin tests |
| Same structure as e-sign | 1 | Markup review + visual QA |
| Message uses setting; no picker | 2, 8 | Workflow-message + modal tests |
| Manual batch first notice exists; no automatic daily send | 7, 8, 12 | Batch API/worker/UI tests + negative scheduler test |
| One deliberate company-wide action covers all eligible employees in scope | 7, 8, 12 | Snapshot-token + beyond-page/beyond-100 E2E; one parent batch with internal chunks |
| Batch preflight shows eligible/skipped reasons | 7, 8 | Scoped preflight + modal tests |
| Batch independently reports success/skip/failure | 7, 8, 12 | Partial-failure integration + E2E |
| Parent batch state is visible independently from item counters | 7, 8, 12 | Materializing/stopping/completed-with-issues UI and E2E |
| Failed-only retry creates no duplicates | 4, 6, 7, 12 | Stable request-key and retry E2E |
| Admin can stop the unstarted remainder of a batch | 3, 7, 8, 12 | Cancellation-state, audit, and in-flight-settlement E2E |
| First Messenger notice has Sign link | 4–8 | Sign contract test + E2E |
| Waiting request can send a fixed reminder without new contract/deadline | 2, 6, 8, 12 | Reminder copy/CTA/idempotency/state-boundary E2E |
| Reminder is a distinct workflow-message purpose | 2, 6, 12 | Enum/migration/serialization/render/search regression tests |
| Signing access is employee-authorized and short-lived | 4–6, 12 | S6 session/terminal/cross-tenant/security-boundary tests |
| Sign request exists and waits | 4–8 | API integration + e-sign assertion |
| Notice alone does not apply | 6 | State/API test |
| Sign supports signature-only consent without employee plan fields | 4 | Real template/create-send test + negative plan-field assertion |
| Signature completion applies without requiring or showing an employee plan | 9 | Authenticated Inbox/webhook/result test + E2E |
| Original visible in e-sign list | 5, 9 | E-sign API/UI assertion |
| Consent signature and company designation do not reduce balance | 3, 9, 11 | Negative ledger/request assertions |
| Deadline auto-cancels | 10 | Boundary/concurrency/real cancel tests |
| Expired cancellation is preserved as business state; designation is derived | 3, 8, 10, 11 | EXPIRED_CANCELLED label + canSecondDesignate API/UI matrix |
| Confirmed terminal request can be explicitly reissued without losing history | 3, 5, 6, 9, 12 | New attempt/e-sign/key E2E + old-record immutability assertions |
| Eligible admin designates | 11 | Seven-rule matrix |
| Second message has reason + schedule | 11 | Stored message + UI assertions |
| Completed leaves needed filter | 11 | Query/UI test |
11. Review and Release Slices
- Slice A — TC20: settings, creation paths, official workflow room/message, Messenger rendering.
- Slice B — Sign external API: corporation-scoped selected template, idempotent create-send, recipient reference/short-lived session, DHub-derived deadline synchronization, cancel, result, status/webhook.
- Slice C — Individual first notice: DHub promotion entities/state, Sign adapter, one-employee saga, annual-leave row UI.
- Slice D — Manual batch first notice: durable batch/item entities, preflight/submit, worker, concurrency/retry/stop, batch UI/history, partial-failure E2E.
- Slice E — Completion: authenticated webhook Inbox/result, reconciler, applied/review-required state, timeline/e-sign proof.
- Slice F — Expiry/second designation: worker, cancel, eligibility, schedule UI, second message, E2E/alerts.
Slices remain the implementation/review/test order. Patch 7 source, migration, and test edits require Eron's explicit approval under the execution rule. Do not combine unrelated Holder-provisioning or generic Messenger refactors.
12. Definition of Done
- Every traceability row has automated/local integration proof. Staging/live proof is explicitly deferred to the separately authorized rollout gate.
- Messenger is mandatory for both notices.
- Display sender, executor, Sign Holder, Sign creator, recipient, and signer remain distinct.
- Employee reaches Sign only through a DHub-authorized, short-lived signer session, never a stored reusable URL or Holder/Admin redirect.
- Individual and manual batch first send, completion, cancellation, and second designation are tenant-scoped, idempotent, and recoverable.
- One deliberate full-company action creates one parent batch covering every eligible employee in scope; internal 100-item chunks are invisible to the admin. The batch survives page navigation/process restart, reports per-item truth, can stop its unstarted remainder, and retries failed items without duplicate contracts/messages.
- Existing organization credential -> Sign corporation template visibility remains the only template boundary; individual/batch flows select and snapshot one compatible template and Sign revalidates it.
- DHub derives the business deadline from committed Messenger
messagePublishedAt; Sign acknowledges and enforces the identical absoluteexpiresAt. Publication, push delivery, read, and Sign send timestamps remain distinct. ESignRecordis the sole local Sign integration SoT; promotion does not duplicate external contract/request/recipient/template/remote-state fields.- Webhooks are HMAC/replay protected, durably acknowledged through an Inbox, tenant-resolved, deduplicated, and monotonic; Outbox notification retries never duplicate Messenger history.
- Business status is independent from processing failure/retry/manual-review status; uncertain remote outcomes are reconciled before any replacement contract.
EXPIRED_CANCELLEDremains an explicit auditable business state;지정 필요is derived fromcanSecondDesignate, never stored as a substitute status.- Recoverable retries reuse one attempt/key. Confirmed terminal reissue requires explicit admin action and a new immutable Sign attempt/
ESignRecord/key while preserving old history. - Workflow purposes distinguish first, reminder, and second messages; S5 uses
plannedPublicationAt, and only the successful publication transaction writes the equalmessagePublishedAt. - Frozen workflow titles are persisted message snapshots and render identically from history and realtime; the workflow conversation title is not used as a substitute.
- After S5 success, e-sign, current attempt, promotion, Message publication/unread/timeline, and Outbox truth commit atomically with one identical final deadline/revision.
- Waiting reconciliation never leaves a non-completed Sign request in
processing; completion processing uses an idempotent CAS claim and deterministic failure exit. - Parent batch status—including materializing, stopping, and completed-with-issues—is visible independently from item counters.
- No scheduled process discovers targets or sends a first notice without an explicit admin action.
- Second designation cannot enable before Sign confirms cancellation.
- Plan/designation never creates leave request or changes balance.
- Ordinary DMs remain unchanged.
- Both repository builds and all locally available automated/integration tests pass. A real non-production Sign journey is a deployment/rollout gate executed only after separate authorization.
- Source ticket files remain unchanged; section 1.1 and D1–D12 contain the complete frozen implementation decisions, with no product
PENDINGitem.