Skip to content

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 81 while 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:

  1. Completion reconciliation leaves a still-waiting promotion in technical status processing, which blocks 재안내.
  2. The finalized deadline is not persisted to ESignRecord.expiresAt, although Sign, the promotion, and the current attempt already hold the same deadline.
  3. 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-DHubExternalOrganizationReference executed.
  • Contract 81 exists once and is signer_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=none is working.
  • Signer session count is 0; the employee has not opened the signing session.

Failed before this repair ​

  • AnnualLeavePromotion.processingStatus is processing while the current Sign request is still waiting. It should remain completed when reconciliation finds no completed result.
  • ESignRecord.expiresAt is NULL, while Sign, AnnualLeavePromotion.expiresAt, and AnnualLeavePromotionSignAttempt.deadlineAt contain 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 81 committed 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_required outcome came from obsolete plan validation and must be removed by Patch 7.
  • Contract 81 is 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:

  1. changes the promotion from completed to processing;
  2. calls Sign result lookup;
  3. 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:

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

3.2 The first-notice success boundary is split across transactions ​

After S5 acknowledges the deadline, the current flow performs independent writes:

  1. publishWorkflowMessage() publishes the Message, increments unread state, and creates Outbox in its own transaction;
  2. the Sign attempt is saved separately;
  3. the promotion is updated separately;
  4. ESignRecord.expiresAt is 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:

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

3.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:

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

4. Frozen Decisions ​

4.1 Reconciliation state rule ​

  • A remote result that is not completed is normal for an AWAITING_SIGNATURE case.
  • 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_SIGNATURE until validated completion succeeds.

4.2 Deadline source-of-truth rule ​

  • ESignRecord.expiresAt is the canonical local Sign-integration deadline.
  • AnnualLeavePromotionSignAttempt.deadlineAt and AnnualLeavePromotion.expiresAt are business/read-model snapshots, not independently editable values.
  • After successful publication, all three values must be equal to the exact expiresAt returned by S5.
  • ESignRecord.deadlineRevision and 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 provisionalExpiresAt as the final deadline.

4.3 Atomic post-S5 publication rule ​

After S5 returns the expected deadline, one DHub transaction must:

  1. lock/reload the prepared Message, promotion, current attempt, and its ESignRecord inside the organization scope;
  2. verify the attempt is still current and all rows still represent the same operation/request key;
  3. update ESignRecord.expiresAt and deadlineRevision;
  4. update attempt publication time, deadline, revision, message reference, and status;
  5. update promotion business/processing state and deadline snapshot;
  6. publish the Message with messagePublishedAt = plannedPublicationAt;
  7. update conversation preview/unread state;
  8. append the promotion event;
  9. 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" or workflowTitle is present;
  • never derive the title from workflowPurpose in the client;
  • never prepend the title into content.

Frozen titles:

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

text
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 abandoned processing state.
  • Keep organization and current-attempt predicates in every read/update.

Task 2 — Make post-S5 local publication atomic ​

Files:

text
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 EntityManager transaction.
  • Avoid nested independent transactions.
  • Preserve the existing idempotent PUBLISHED behavior 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:

text
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 workflowTitle as a nullable snapshot column with an appropriate bounded length.
  • Backfill all three existing annual-leave workflow purposes in the migration.
  • Persist input.title when preparing a workflow Message.
  • Include the title in list/history mapping, realtime events, and Outbox delivery payloads.
  • Extend MessageItem with origin and workflowTitle without using any as 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:

  1. 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.
  2. Set ESignRecord.expiresAt and revision from the verified attempt/S5 truth.
  3. Reset a stale promotion processing -> completed only when the current attempt and e-sign remote state are still waiting and no completion/review error exists.
  4. Re-read all related rows and compare them with Sign status.
  5. 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: 25 test files / 310 tests passed, including all four reconciliation result statuses and workflow-message atomicity/idempotency cases.
  • packages/components: 16 test files / 289 tests passed, including history/realtime workflow-title mapping and ordinary-message null-title behavior.
  • @hereby/api build and @hereby/components build passed.
  • Repository pnpm build passed. pnpm lint:check remains blocked by existing diagnostics, including legacy any/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 completed and business status AWAITING_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 processing indefinitely.
  • 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.
  • provisionalExpiresAt is never exposed as final expiresAt.

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-sign remains 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.

  1. Log in to the employee App as UserTest.
  2. Open the official workflow Messenger conversation.
  3. Verify sender: hereby · noreply@hereby.me or the configured organization snapshot.
  4. Verify visible title: 연차 사용촉진 안내.
  5. Verify the frozen body, remaining balance, and 2026-08-16 deadline.
  6. Verify 서명하기 is visible and the conversation is non-reply.
  7. Before clicking, verify signer session count remains 0.
  8. 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.
  9. Verify 재안내 is accepted while the case is waiting and does not create a new Sign contract or change the deadline.
  10. Verify the reminder displays its own frozen title.

8. Verification Commands ​

Run the focused tests first, followed by repository gates:

bash
pnpm --filter @hereby/api test -- annual-leave-promotion
pnpm --filter @hereby/components test
pnpm lint:check
pnpm check:modals
pnpm check:dialogs
pnpm build

Use 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 81 is 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 fail until 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.