Annual Leave Promotion — State Consistency and Workflow Title Repair Plan
Status: Patch 5 implementation, guarded local repair, focused regression coverage, package builds, and employee title/session verification are complete as of 2026-08-07. Database-backed concurrent-worker proof and the repository lint baseline remain. Final completion E2E exposed Sign completion-response/webhook defects owned by Patch 6. The empty employee plan is expected under Eron's 2026-08-10 signature-only correction and is handled by Patch 7.
Execution rule: Follow the repository approval rules before changing code. Do not resend, cancel, complete, or delete Sign contract
81while implementing this repair. Do not click or submit the employee signature until the post-fix QA step explicitly begins.
1. Goal
Repair three release-blocking defects found after the first successful local annual-leave promotion send:
- Completion reconciliation leaves a still-waiting promotion in technical status
processing, which blocks재안내. - The finalized deadline is not persisted to
ESignRecord.expiresAt, although Sign, the promotion, and the current attempt already hold the same deadline. - Messenger receives the frozen workflow title as service input but neither persists nor renders it, so the employee sees the body and CTA without
연차 사용촉진 안내.
The repair must restore one atomic local success boundary after Sign deadline synchronization and preserve ordinary Messenger behavior.
2. Confirmed QA Evidence
Passed before this repair
- DHub focused tests:
28/28. - Sign focused tests:
47/47. - Sign migration
1786000000000-DHubExternalOrganizationReferenceexecuted. - Contract
81exists once and issigner_waiting. - Sign deadline is
2026-08-16. - The Messenger message is published and its Outbox row is
delivered. - No duplicate valid contract or additional ticket charge occurred.
- No email or SMS was sent;
notificationPolicy=noneis working. - Signer session count is
0; the employee has not opened the signing session.
Failed before this repair
AnnualLeavePromotion.processingStatusisprocessingwhile the current Sign request is still waiting. It should remaincompletedwhen reconciliation finds no completed result.ESignRecord.expiresAtisNULL, while Sign,AnnualLeavePromotion.expiresAt, andAnnualLeavePromotionSignAttempt.deadlineAtcontain the final deadline.- Employee Messenger shows the body and
서명하기, but not the frozen title연차 사용촉진 안내.
Historical fact, not a new defect
Contract 81 emitted its first webhook before the first-webhook binding-race repair, so that historical event is absent from the DHub Inbox. Do not replay or resend contract 81 merely to manufacture that missing historical Inbox row.
Post-Patch-5 QA outcome (2026-08-07)
- Employee Messenger rendered the frozen title, sender, body, deadline, and
서명하기CTA correctly. - Signer-session access succeeded.
- Final completion is not a Patch 5 pass: contract
81committed completed in Sign, the first response showed an unknown error, and no completion webhook reached DHub. - The submitted employee plan was empty, which is valid under the 2026-08-10 signature-only scope. DHub's
review_requiredoutcome came from obsolete plan validation and must be removed by Patch 7. - Contract
81is now immutable failed-E2E evidence. Continue only after Patch 7 and the remaining Patch 6 reliability gates, using a fresh designated QA case.
3. Root Causes
3.1 Waiting reconciliation mutates processing state too early
reconcilePromotionCompletion() currently:
- changes the promotion from
completedtoprocessing; - calls Sign result lookup;
- returns immediately when the remote result is not
completed.
The early return does not restore the previous stable processing state. The five-minute reconciler repeats this path for every AWAITING_SIGNATURE promotion.
Relevant files:
packages/api/src/services/annual-leave-promotion.service.ts
packages/api/src/jobs/annual-leave-promotion-reconcile.job.ts
packages/api/src/services/annual-leave-promotion-state.service.ts3.2 The first-notice success boundary is split across transactions
After S5 acknowledges the deadline, the current flow performs independent writes:
publishWorkflowMessage()publishes the Message, increments unread state, and creates Outbox in its own transaction;- the Sign attempt is saved separately;
- the promotion is updated separately;
ESignRecord.expiresAtis never updated.
This permits a published employee message with incomplete local Sign/promotion truth. Merely adding one ESignRecord.update() call is insufficient; all local post-S5 success writes must commit together.
Relevant files:
packages/api/src/services/annual-leave-promotion.service.ts
packages/api/src/services/workflow-message.service.ts
packages/database/src/entities/e-sign-record.entity.ts
packages/database/src/entities/annual-leave-promotion-sign-attempt.entity.ts
packages/database/src/entities/annual-leave-promotion.entity.ts3.3 Workflow title is accepted and discarded
PrepareWorkflowMessageInput.title exists, and first/reminder/second callers supply a frozen title. prepareWorkflowMessage() does not write that value to Message, Message has no workflow-title column, realtime payloads omit it, and Messenger renders only msg.content.
Relevant files:
packages/api/src/services/workflow-message.service.ts
packages/api/src/events/message-events.ts
packages/database/src/entities/message.entity.ts
packages/components/src/components/Messenger.ts
packages/components/html/messenger-content.html
packages/database/src/migrations/<next>-AddWorkflowMessageTitle.ts4. Frozen Decisions
4.1 Reconciliation state rule
- A remote result that is not
completedis normal for anAWAITING_SIGNATUREcase. - It must not change the stable local processing status from
completed. - Only a confirmed remote completion may claim completion processing through an atomic compare-and-set.
- A recoverable remote/result fetch failure uses the existing retryable technical failure path; it must never remain indefinitely in
processing. - Invalid signer/template/plan evidence remains
review_required. - Business status stays
AWAITING_SIGNATUREuntil validated completion succeeds.
4.2 Deadline source-of-truth rule
ESignRecord.expiresAtis the canonical local Sign-integration deadline.AnnualLeavePromotionSignAttempt.deadlineAtandAnnualLeavePromotion.expiresAtare business/read-model snapshots, not independently editable values.- After successful publication, all three values must be equal to the exact
expiresAtreturned by S5. ESignRecord.deadlineRevisionand the attempt revision must also equal the acknowledged S5 revision.- All reads that authorize signer access use the current attempt and canonical e-sign deadline invariant; no path may substitute
provisionalExpiresAtas the final deadline.
4.3 Atomic post-S5 publication rule
After S5 returns the expected deadline, one DHub transaction must:
- lock/reload the prepared Message, promotion, current attempt, and its
ESignRecordinside the organization scope; - verify the attempt is still current and all rows still represent the same operation/request key;
- update
ESignRecord.expiresAtanddeadlineRevision; - update attempt publication time, deadline, revision, message reference, and status;
- update promotion business/processing state and deadline snapshot;
- publish the Message with
messagePublishedAt = plannedPublicationAt; - update conversation preview/unread state;
- append the promotion event;
- insert exactly one Outbox row.
If the transaction fails, the Message remains hidden and no unread/Outbox/business-success state is committed. Retry uses the same Sign request/attempt and a higher S5 revision as already defined in the main delivery plan.
Do not hold a database transaction open during S1 or S5 HTTP calls.
4.4 Workflow title storage/rendering rule
Add a nullable Message.workflowTitle snapshot:
- ordinary messages:
NULL; - workflow messages: required by service validation and persisted at preparation time;
- API list/history and realtime/Outbox delivery: return the persisted snapshot;
- Messenger: render it above the body only when
msg.origin === "workflow"orworkflowTitleis present; - never derive the title from
workflowPurposein the client; - never prepend the title into
content.
Frozen titles:
annual_leave_promotion_first -> 연차 사용촉진 안내
annual_leave_promotion_reminder -> 연차 사용촉진 동의서 서명 안내
annual_leave_promotion_second -> 연차 사용시기 지정 안내The migration backfills existing workflow messages from their persisted workflowPurpose, then enforces the service-level invariant for new messages. Do not add client compatibility fallbacks.
5. Implementation Tasks
Task 1 — Fix reconciliation claiming and stable-state restoration
Files:
packages/api/src/services/annual-leave-promotion.service.ts
packages/api/src/jobs/annual-leave-promotion-reconcile.job.ts
packages/api/src/services/annual-leave-promotion.service.test.ts- Fetch/reconcile remote status before changing stable processing state, or use a separate durable claim that does not overwrite business-visible processing status.
- When the result is not completed, return without a local status mutation.
- For a completed result, CAS only from the expected current promotion/attempt state into completion processing.
- If another worker already owns or completed the transition, return idempotently.
- Ensure every thrown path after a successful claim ends in a retryable failure or
review_required; never leave an abandonedprocessingstate. - Keep organization and current-attempt predicates in every read/update.
Task 2 — Make post-S5 local publication atomic
Files:
packages/api/src/services/annual-leave-promotion.service.ts
packages/api/src/services/workflow-message.service.ts
packages/database/src/entities/e-sign-record.entity.ts
co-located tests- Refactor workflow publication so it can participate in a caller-owned
EntityManagertransaction. - Avoid nested independent transactions.
- Preserve the existing idempotent
PUBLISHEDbehavior and unique Outbox event key. - In the same transaction, persist the exact S5 deadline/revision to e-sign, attempt, and promotion.
- Assert the e-sign request key, attempt relation, promotion current-attempt pointer, message reference, organization, and S5 contract ID agree before mutation.
- Return the transactionally reloaded result rather than merging stale in-memory entities.
Task 3 — Persist and render workflow-message titles
Files:
packages/database/src/entities/message.entity.ts
packages/database/src/migrations/<next>-AddWorkflowMessageTitle.ts
packages/api/src/services/workflow-message.service.ts
packages/api/src/events/message-events.ts
packages/components/src/components/Messenger.ts
packages/components/html/messenger-content.html
co-located tests- Add
workflowTitleas a nullable snapshot column with an appropriate bounded length. - Backfill all three existing annual-leave workflow purposes in the migration.
- Persist
input.titlewhen preparing a workflow Message. - Include the title in list/history mapping, realtime events, and Outbox delivery payloads.
- Extend
MessageItemwithoriginandworkflowTitlewithout usinganyas the final design. - Render the title as escaped text above the body with existing design-system typography/tokens.
- Keep the CTA outside the body bubble as it is today.
- Do not change the workflow conversation title (
{employeeName} 공식 안내); it is separate from the per-message frozen title. - Preserve ordinary direct/group message layout exactly.
Task 4 — Repair existing local QA truth safely
Contract 81 is local QA evidence, not a reason to hard-code an ID in production code or migrations.
Use a guarded, reviewed repair after the code change:
- Dry-run select rows where:
- promotion is
AWAITING_SIGNATURE; - current attempt is
AWAITING_SIGNATURE; - e-sign record is the attempt's relation in the same organization;
- attempt and promotion deadlines are both non-null and equal;
ESignRecord.expiresAt IS NULL;- the prepared message is already
PUBLISHED.
- promotion is
- Set
ESignRecord.expiresAtand revision from the verified attempt/S5 truth. - Reset a stale promotion
processing -> completedonly when the current attempt and e-sign remote state are still waiting and no completion/review error exists. - Re-read all related rows and compare them with Sign status.
- Skip ambiguous rows and report them for manual review.
Because this feature has not been rolled out, do not add a permanent production data migration solely for contract 81. If any shared/staging environment already contains affected rows, convert the guarded repair into a reviewed migration or operational script before rollout.
6. Required Automated Tests
6.0 Current verification record (2026-08-07)
packages/api:25test files /310tests passed, including all four reconciliation result statuses and workflow-message atomicity/idempotency cases.packages/components:16test files /289tests passed, including history/realtime workflow-title mapping and ordinary-message null-title behavior.@hereby/apibuild and@hereby/componentsbuild passed.- Repository
pnpm buildpassed.pnpm lint:checkremains blocked by existing diagnostics, including legacyany/suppression findings elsewhere in the shared Messenger file; the new focused test files pass the targeted Biome check. - Remaining automated proof: database-backed rollback semantics and two concurrent reconciler workers; the current unit tests cover transaction-boundary propagation and duplicate-publication idempotency.
6.1 Reconciliation
- Waiting Sign result leaves promotion
completedand business statusAWAITING_SIGNATURE. - Repeated waiting reconciliation is idempotent.
- Confirmed completion performs one CAS claim and one application transaction.
- Two concurrent reconcilers cannot both save the same completion event/state transition.
- Remote lookup failure cannot leave
processingindefinitely. - Invalid signer identity or required signature evidence moves to
review_required; absent employee-plan data does not. - Historical-attempt completion cannot regress the current attempt.
6.2 Deadline and atomic publication
- Successful first notice writes identical final instants to:
- S5 response;
ESignRecord.expiresAt;- current attempt
deadlineAt; - promotion
expiresAt; messagePublishedAt + responseDurationSecondsSnapshot.
- E-sign and attempt deadline revisions equal the acknowledged S5 revision.
- Failure while updating any one local row rolls back Message publication, unread increment, promotion event, and Outbox insert.
- Retry after rollback reuses the contract/request key and creates only one published Message/Outbox event.
provisionalExpiresAtis never exposed as finalexpiresAt.
6.3 Workflow titles
- First, reminder, and second messages persist and serialize their exact frozen titles.
- API history load and realtime arrival produce the same rendered title.
- Existing workflow rows are backfilled correctly by migration.
- Title and body are escaped text; HTML is not interpreted.
- Ordinary direct/group messages have no new title or spacing regression.
messenger-annual-leave-signremains visible for the first/reminder CTA.
7. Manual QA Sequence
Before starting, confirm the guarded data repair made the current records consistent. Do not resend contract 81.
- Log in to the employee App as UserTest.
- Open the official workflow Messenger conversation.
- Verify sender:
hereby · noreply@hereby.meor the configured organization snapshot. - Verify visible title:
연차 사용촉진 안내. - Verify the frozen body, remaining balance, and
2026-08-16deadline. - Verify
서명하기is visible and the conversation is non-reply. - Before clicking, verify signer session count remains
0. - In a separately authorized end-to-end step, click once and verify exactly one ten-minute signer session; do not complete the signature until final completion QA is approved.
- Verify
재안내is accepted while the case is waiting and does not create a new Sign contract or change the deadline. - Verify the reminder displays its own frozen title.
8. Verification Commands
Run the focused tests first, followed by repository gates:
pnpm --filter @hereby/api test -- annual-leave-promotion
pnpm --filter @hereby/components test
pnpm lint:check
pnpm check:modals
pnpm check:dialogs
pnpm buildUse the repository's actual package scripts if the focused command names differ; do not skip pnpm build, because Vitest does not type-check test files.
No Sign source change is expected. Rerun the existing Sign 47/47 focused suite as a regression gate after DHub integration QA.
9. Acceptance Criteria
- A waiting reconciliation never changes a stable promotion to stuck
processing. 재안내is accepted for the valid waiting case.- The final deadline and revision are consistent across Sign, e-sign, current attempt, promotion, and published message derivation.
- Message publication plus local Sign/promotion success truth is atomic.
- Messenger visibly renders the exact frozen title, body, sender snapshots, and CTA.
- Ordinary Messenger conversations are unchanged.
- Contract
81is neither resent nor charged again during repair. - No email/SMS is emitted.
- Focused tests, lint, dialog/modal gates, and full build pass.
- Post-fix QA remains
failuntil the employee-side title check and the full completion-webhook terminal journey are separately verified.
10. Out of Scope
- Replaying the historical first webhook for contract
81. - Redesigning the complete annual-leave processing-status enum.
- Batch worker cross-instance concurrency and permission-scope repairs from the broader audit.
- Changing the frozen Korean copy.
- Adding Sign-native email/SMS.
- Creating a new Sign contract to replace contract
81. - Completing the employee signature during the implementation repair.