feat: require explicit model-operation holds (AEG-V15-037)
Separates business holds from technical failures in the pure execution state machine. Evidence: targeted Release tests 3/3 passed; TRX SHA256 2F2CD06B1DFD3F76F336A0636598553599CD05CF7FA82E477DB160425975085F.
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
# AEG-V15-037 — Business hold versus technical failure
|
||||
|
||||
## Scope
|
||||
|
||||
- **WBS ID:** `AEG-V15-037`; **Requirement / API / DB / Job / UI / Test:** `REQ-V15-SCH-05` / `V15-SCH-API` / `MIG-0020` / `J31~J40` / `Cross` / `T-V15-SCH-05`.
|
||||
- **Dependency / Gate:** `AEG-V15-036` (completed, commit `00957bf`) / `G3`.
|
||||
- **Acceptance:** no blind retry; every business hold records reason and hold-until.
|
||||
|
||||
## Source / Assumption / Unknown / Decision Required
|
||||
|
||||
- **Source:** `ModelOperationExecution` permits both `BusinessHold` and `Failed`, while `MIG-0020` provides `hold_until` and `status_reason_code`. The current pure state machine does not model a hold expiry.
|
||||
- **Assumption:** `BusinessHold` requires a future `holdUntil` and may resume only through an explicit subsequent transition. A technical `Failed` transition must not accept a hold expiry, making retry disposition explicit rather than implicit.
|
||||
- **Unknown:** the execution-state persistence/update handler is not yet implemented in the active path; this Slice changes the pure policy contract and tests it without inventing a new persistence workflow.
|
||||
- **Decision Required:** automated retry limits/backoff for technical failures are not approved by the schedule contract and remain outside this Slice.
|
||||
|
||||
## Actual evidence
|
||||
|
||||
- `dotnet test tests/KArtSell.ModelOperations.UnitTests/KArtSell.ModelOperations.UnitTests.csproj -c Release --filter FullyQualifiedName~ModelOperationExecutionTests --logger "trx;LogFileName=ModelOperationExecutionTests_20260809.trx" --results-directory evidence/AEG-V15-037`
|
||||
- Result: passed `3/3`; artifact: `evidence/AEG-V15-037/ModelOperationExecutionTests_20260809.trx`; SHA-256: `2F2CD06B1DFD3F76F336A0636598553599CD05CF7FA82E477DB160425975085F`.
|
||||
- The policy is deterministic and I/O-free: a hold has an explicit reason and future expiry, explicit resume clears it, and a technical failure cannot masquerade as a timed business hold. No retry policy, schedule activation, or persistence workflow was introduced.
|
||||
@@ -515,7 +515,7 @@ AEG-V15-033,S8,W17-18,VS-18,BacktestingEvaluation,Scheduler,SCH,REQ-V15-SCH-01,V
|
||||
AEG-V15-034,S8,W17-18,VS-18,BacktestingEvaluation,Scheduler,SCH,REQ-V15-SCH-02,V15-SCH-API,MIG-0020,J31~J40,Cross,T-V15-SCH-02,Catch-up policy 구현,LATEST/SKIP/ALL_LIMIT,장애 복구 후 job storm 0,BE Lead,SRE/QA,5,AEG-V15-033,G3,SOURCE+DESIGN_PROPOSAL,P0,COMPLETED
|
||||
AEG-V15-035,S8,W17-18,VS-18,BacktestingEvaluation,Scheduler,SCH,REQ-V15-SCH-03,V15-SCH-API,MIG-0020,J31~J40,Cross,T-V15-SCH-03,Due operation 계약 확장,DueModelOperation v15,scheduledFor/catchUp/maxCatchUp 추적,BE Lead,SRE/QA,5,AEG-V15-034,G3,SOURCE+DESIGN_PROPOSAL,P1,COMPLETED
|
||||
AEG-V15-036,S8,W17-18,VS-18,BacktestingEvaluation,Scheduler,SCH,REQ-V15-SCH-04,V15-SCH-API,MIG-0020,J31~J40,Cross,T-V15-SCH-04,Dispatcher nextDue CAS,schedule repository,lease 상실·중복 advance 0,BE Lead,SRE/QA,6,AEG-V15-035,G3,SOURCE+DESIGN_PROPOSAL,P0,COMPLETED
|
||||
AEG-V15-037,S8,W17-18,VS-18,BacktestingEvaluation,Scheduler,SCH,REQ-V15-SCH-05,V15-SCH-API,MIG-0020,J31~J40,Cross,T-V15-SCH-05,BusinessHold와 기술실패 분리,execution state machine,"blind retry 0, reason/holdUntil 기록",BE Lead,SRE/QA,5,AEG-V15-036,G3,SOURCE+DESIGN_PROPOSAL,P1,PLANNED
|
||||
AEG-V15-037,S8,W17-18,VS-18,BacktestingEvaluation,Scheduler,SCH,REQ-V15-SCH-05,V15-SCH-API,MIG-0020,J31~J40,Cross,T-V15-SCH-05,BusinessHold와 기술실패 분리,execution state machine,"blind retry 0, reason/holdUntil 기록",BE Lead,SRE/QA,5,AEG-V15-036,G3,SOURCE+DESIGN_PROPOSAL,P1,COMPLETED
|
||||
AEG-V15-038,S8,W17-18,VS-18,BacktestingEvaluation,Scheduler,SCH,REQ-V15-SCH-06,V15-SCH-API,MIG-0020,J31~J40,Cross,T-V15-SCH-06,Schedule heartbeat/aging,execution heartbeat,stuck request 탐지와 Owner alert,BE Lead,SRE/QA,6,AEG-V15-037,G3,SOURCE+DESIGN_PROPOSAL,P0,PLANNED
|
||||
AEG-V15-039,S8,W17-18,VS-18,BacktestingEvaluation,Scheduler,SCH,REQ-V15-SCH-07,V15-SCH-API,MIG-0020,J31~J40,Cross,T-V15-SCH-07,시장 timezone/calendar 계약,calendar/timezone contract,UTC 저장·시장세션 계산·DST 테스트,BE Lead,SRE/QA,4,AEG-V15-038,G3,SOURCE+DESIGN_PROPOSAL,P1,PLANNED
|
||||
AEG-V15-040,S8,W17-18,VS-18,BacktestingEvaluation,Scheduler,SCH,REQ-V15-SCH-08,V15-SCH-API,MIG-0020,J31~J40,Cross,T-V15-SCH-08,Scheduler chaos rehearsal,dispatcher chaos tests,enqueue/mark 실패·재시작 중복 side effect 0,BE Lead,SRE/QA,4,AEG-V15-039,G3,SOURCE+DESIGN_PROPOSAL,P1,PLANNED
|
||||
|
||||
|
@@ -10,6 +10,7 @@ AEG-V15-033,S8,VS-18,Schedule anchor 계산기,COMPLETED,2026-08-09,"docs/CURREN
|
||||
AEG-V15-034,S8,VS-18,Catch-up policy 구현,COMPLETED,2026-08-09,"docs/CURRENT/AEG-V15-034_CATCH_UP_POLICY_SLICE_NOTE.md; src/KArtSell.Modules.ModelOperations/Domain/ScheduleOccurrencePlanner.cs; src/KArtSell.Modules.ModelOperations/Scheduling/ModelOperationsDispatcherJob.cs; src/KArtSell.Modules.ModelOperations/Infrastructure/DapperModelScheduleRepository.cs; tests/KArtSell.ModelOperations.UnitTests/ScheduleOccurrencePlannerTests.cs; evidence/AEG-V15-034/ScheduleOccurrencePlannerTests_20260809.trx",BE Lead,"Actual Release run: targeted ScheduleOccurrencePlannerTests 4/4 passed. LATEST_ONLY dispatches only the latest anchored occurrence; SKIP_MISSED advances the lease-held schedule without enqueueing stale work; ALL_WITH_LIMIT dispatches only the configured most-recent occurrences, each with an occurrence-specific UTC idempotency key. Schedules remain disabled. Database integration, lease CAS, and DEC-083 enqueue/mark atomicity are not claimed and remain owned by later WBS items."
|
||||
AEG-V15-035,S8,VS-18,Due operation 계약 확장,COMPLETED,2026-08-09,"docs/CURRENT/AEG-V15-035_DUE_OPERATION_CONTRACT_SLICE_NOTE.md; src/KArtSell.Modules.ModelOperations/Application/ModelOperationsContracts.cs; src/KArtSell.Modules.ModelOperations/Application/ModelOperationRequestService.cs; src/KArtSell.Modules.ModelOperations/Scheduling/ScheduledModelOperationJob.cs; src/KArtSell.Modules.ModelOperations/Infrastructure/DapperModelOperationRequestRepository.cs; tests/KArtSell.ModelOperations.UnitTests/ModelOperationRequestServiceTests.cs; evidence/AEG-V15-035/DueModelOperationContractTests_20260809.trx",BE Lead,"Actual Release run: 5/5 targeted unit tests passed. The scheduler occurrence, catch-up policy, and max catch-up flow from due schedule through the serialized job and validated application request; scheduled_for is inserted in the normalized request model and all three values are retained in the transactional outbox payload. Schedules remain disabled. No new migration or PostgreSQL integration evidence is claimed: MIG-0020 already provides scheduled_for; policy and limit provenance is immutable in the event payload, while schedule configuration remains the normalized source referenced by schedule_id/version."
|
||||
AEG-V15-036,S8,VS-18,Dispatcher nextDue CAS,COMPLETED,2026-08-09,"docs/CURRENT/AEG-V15-036_DISPATCH_CAS_SLICE_NOTE.md; src/KArtSell.Modules.ModelOperations/Application/ModelOperationsContracts.cs; src/KArtSell.Modules.ModelOperations/Infrastructure/DapperModelScheduleRepository.cs; src/KArtSell.Modules.ModelOperations/Scheduling/ModelOperationsDispatcherJob.cs; tests/KArtSell.ModelOperations.UnitTests/DapperModelScheduleRepositoryContractTests.cs; tests/KArtSell.Integration.Tests/Scheduling/ModelScheduleCasTests.cs; evidence/AEG-V15-036/DispatcherCasContractTests_20260809.trx; evidence/AEG-V15-036/ModelScheduleCasTests_20260809.trx",BE Lead,"Actual evidence: unit contract tests 8/8 passed and PostgreSQL integration ModelScheduleCasTests 1/1 passed. The integration test acquires an isolated schedule, expires/reacquires its lease, and verifies a stale owner/revision cannot mutate next_due_at (0-row CAS) while the current owner/revision remains. It found and fixed Dapper positional record materialization by mapping a SQL row DTO explicitly to DueModelOperation. Schedules remain disabled; DEC-083 enqueue/mark atomicity remains a separate later Slice."
|
||||
AEG-V15-037,S8,VS-18,BusinessHold와 기술실패 분리,COMPLETED,2026-08-09,"docs/CURRENT/AEG-V15-037_EXECUTION_HOLD_SLICE_NOTE.md; src/KArtSell.Modules.ModelOperations/Domain/ModelOperationExecution.cs; tests/KArtSell.ModelOperations.UnitTests/ModelOperationExecutionTests.cs; evidence/AEG-V15-037/ModelOperationExecutionTests_20260809.trx",BE Lead,"Actual Release evidence: ModelOperationExecutionTests 3/3 passed. The pure state machine requires a future holdUntil plus reason for BUSINESS_HOLD, clears it only through explicit resume, and rejects holdUntil for FAILED. This prevents a business hold from becoming a blind technical retry. No unapproved retry/backoff, schedule activation, persistence workflow, or threshold was added."
|
||||
AEG-V16-017,S6,Cross,FieldShell 표준,IN_PROGRESS,TBD,"docs/CURRENT/AEG-V16-017_FIELDSHELL_SLICE_NOTE.md; frontend/src/shared/ui/components/FieldShell.vue; frontend/src/shared/ui/components/tests/FieldShell.spec.ts","FE Lead","2026-08-08: FieldShell now owns label/error/help/ARIA relationships for KsTextField, KsTextArea, KsSelect, KsDateField, and KsNumberField. Actual evidence: frontend pnpm typecheck PASS; pnpm test PASS (19 files, 42 tests); pnpm build PASS. Build emitted unrelated tracked .js drift, excluded from this Slice. COMPLETED is blocked pending WBS Master/tracker reconciliation and AEG-V16-016 vendor-boundary acceptance evidence."
|
||||
AEG-V16-016,S0,VS-00,Vendor boundary fitness,IN_PROGRESS,TBD,"docs/CURRENT/AEG-V16-016_VENDOR_BOUNDARY_SLICE_NOTE.md; tools/validate_v16.py; frontend/src/shared/ui/adapter/tests/uiAdapter.contract.spec.ts; evidence/AEG-V16-016/validate_v16_20260808.log; evidence/AEG-V16-016/ui-adapter-tests_20260808.log; evidence/AEG-V16-016/frontend-typecheck_20260808.log","FE Lead","2026-08-08: Removed stale fixed WBS row-count assertion; validator now verifies WBS ID integrity and reports vendor imports outside the approved adapter boundary. Re-executed actual evidence: python tools/validate_v16.py PASS=1 WARN=2 FAIL=0; targeted adapter tests 4/4 PASS; frontend typecheck PASS. COMPLETED is blocked because dependency AEG-V16-015 has no approved acceptance evidence in the tracker."
|
||||
AEG-V16-015,S0,VS-00,Adapter rollback runbook,BLOCKED,-,"docs/CURRENT/ui-provider-switch.md","FE Lead","2026-08-08: Runbook exists, but status is BLOCKED before completion: acceptance requires visual/a11y/performance rollback rehearsal evidence, which is not present; direct dependency AEG-V16-014 has no tracker evidence. A runbook does not substitute for an approved visual baseline, keyboard/focus and accessible-name report, state-matrix result, agreed performance budget, immutable-artifact rollback rehearsal, and append-only release evidence. No build/test/migration claimed by this status correction."
|
||||
|
||||
|
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TestRun id="562f7eef-1729-4c99-ad59-0591770f2eb0" name="kjh20@KIMJAEHYUN-NOTE 2026-08-09 02:14:15" runUser="KIMJAEHYUN-NOTE\kjh20" xmlns="http://microsoft.com/schemas/VisualStudio/TeamTest/2010">
|
||||
<Times creation="2026-08-09T02:14:15.0549035+09:00" queuing="2026-08-09T02:14:15.0549038+09:00" start="2026-08-09T02:14:13.7998351+09:00" finish="2026-08-09T02:14:15.0616994+09:00" />
|
||||
<TestSettings name="default" id="ad27cc8b-4f22-4ae8-ac75-706fea057dc4">
|
||||
<Deployment runDeploymentRoot="kjh20_KIMJAEHYUN-NOTE_2026-08-09_02_14_15" />
|
||||
</TestSettings>
|
||||
<Results>
|
||||
<UnitTestResult executionId="3809cddb-e76e-43da-b7d1-a751fcfe9d0a" testId="29eb9b38-c1c2-def5-ee12-9d36c3ca99fd" testName="KArtSell.ModelOperations.UnitTests.ModelOperationExecutionTests.Business_hold_requires_a_future_expiry_but_technical_failure_cannot_set_one" computerName="KIMJAEHYUN-NOTE" duration="00:00:00.0004703" startTime="2026-08-09T02:14:14.9549697+09:00" endTime="2026-08-09T02:14:14.9550167+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="3809cddb-e76e-43da-b7d1-a751fcfe9d0a" />
|
||||
<UnitTestResult executionId="2f3b0230-a97b-46a7-a368-2fb61be8fe23" testId="3ad1ce15-14cb-0bca-8778-f23485ec4642" testName="KArtSell.ModelOperations.UnitTests.ModelOperationExecutionTests.Business_hold_can_resume_but_success_is_terminal" computerName="KIMJAEHYUN-NOTE" duration="00:00:00.0015571" startTime="2026-08-09T02:14:14.9543671+09:00" endTime="2026-08-09T02:14:14.9544406+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="2f3b0230-a97b-46a7-a368-2fb61be8fe23" />
|
||||
<UnitTestResult executionId="889dd247-98e8-4069-9e54-0b24fa6292b9" testId="fb07f491-ffe5-b6cc-1d25-ca1d5d81e6d4" testName="KArtSell.ModelOperations.UnitTests.ModelOperationExecutionTests.Business_hold_persists_reason_and_expiry_until_an_explicit_resume" computerName="KIMJAEHYUN-NOTE" duration="00:00:00.0142890" startTime="2026-08-09T02:14:14.9225095+09:00" endTime="2026-08-09T02:14:14.9442331+09:00" testType="13cdc9d9-ddb5-4fa4-a97d-d965ccfc6d4b" outcome="Passed" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" relativeResultsDirectory="889dd247-98e8-4069-9e54-0b24fa6292b9" />
|
||||
</Results>
|
||||
<TestDefinitions>
|
||||
<UnitTest name="KArtSell.ModelOperations.UnitTests.ModelOperationExecutionTests.Business_hold_persists_reason_and_expiry_until_an_explicit_resume" storage="d:\jobroomz\kartsell.aegis\tests\kartsell.modeloperations.unittests\bin\release\net10.0\kartsell.modeloperations.unittests.dll" id="fb07f491-ffe5-b6cc-1d25-ca1d5d81e6d4">
|
||||
<Execution id="889dd247-98e8-4069-9e54-0b24fa6292b9" />
|
||||
<TestMethod codeBase="D:\JobRoomz\KArtSell.Aegis\tests\KArtSell.ModelOperations.UnitTests\bin\Release\net10.0\KArtSell.ModelOperations.UnitTests.dll" adapterTypeName="executor://xunit/VsTestRunner3/netcore/" className="KArtSell.ModelOperations.UnitTests.ModelOperationExecutionTests" name="Business_hold_persists_reason_and_expiry_until_an_explicit_resume" />
|
||||
</UnitTest>
|
||||
<UnitTest name="KArtSell.ModelOperations.UnitTests.ModelOperationExecutionTests.Business_hold_can_resume_but_success_is_terminal" storage="d:\jobroomz\kartsell.aegis\tests\kartsell.modeloperations.unittests\bin\release\net10.0\kartsell.modeloperations.unittests.dll" id="3ad1ce15-14cb-0bca-8778-f23485ec4642">
|
||||
<Execution id="2f3b0230-a97b-46a7-a368-2fb61be8fe23" />
|
||||
<TestMethod codeBase="D:\JobRoomz\KArtSell.Aegis\tests\KArtSell.ModelOperations.UnitTests\bin\Release\net10.0\KArtSell.ModelOperations.UnitTests.dll" adapterTypeName="executor://xunit/VsTestRunner3/netcore/" className="KArtSell.ModelOperations.UnitTests.ModelOperationExecutionTests" name="Business_hold_can_resume_but_success_is_terminal" />
|
||||
</UnitTest>
|
||||
<UnitTest name="KArtSell.ModelOperations.UnitTests.ModelOperationExecutionTests.Business_hold_requires_a_future_expiry_but_technical_failure_cannot_set_one" storage="d:\jobroomz\kartsell.aegis\tests\kartsell.modeloperations.unittests\bin\release\net10.0\kartsell.modeloperations.unittests.dll" id="29eb9b38-c1c2-def5-ee12-9d36c3ca99fd">
|
||||
<Execution id="3809cddb-e76e-43da-b7d1-a751fcfe9d0a" />
|
||||
<TestMethod codeBase="D:\JobRoomz\KArtSell.Aegis\tests\KArtSell.ModelOperations.UnitTests\bin\Release\net10.0\KArtSell.ModelOperations.UnitTests.dll" adapterTypeName="executor://xunit/VsTestRunner3/netcore/" className="KArtSell.ModelOperations.UnitTests.ModelOperationExecutionTests" name="Business_hold_requires_a_future_expiry_but_technical_failure_cannot_set_one" />
|
||||
</UnitTest>
|
||||
</TestDefinitions>
|
||||
<TestEntries>
|
||||
<TestEntry testId="29eb9b38-c1c2-def5-ee12-9d36c3ca99fd" executionId="3809cddb-e76e-43da-b7d1-a751fcfe9d0a" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
|
||||
<TestEntry testId="3ad1ce15-14cb-0bca-8778-f23485ec4642" executionId="2f3b0230-a97b-46a7-a368-2fb61be8fe23" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
|
||||
<TestEntry testId="fb07f491-ffe5-b6cc-1d25-ca1d5d81e6d4" executionId="889dd247-98e8-4069-9e54-0b24fa6292b9" testListId="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
|
||||
</TestEntries>
|
||||
<TestLists>
|
||||
<TestList name="목록에 없는 결과" id="8c84fa94-04c1-424b-9868-57a2d4851a1d" />
|
||||
<TestList name="로드된 모든 결과" id="19431567-8539-422a-85d7-44ee4e166bda" />
|
||||
</TestLists>
|
||||
<ResultSummary outcome="Completed">
|
||||
<Counters total="3" executed="3" passed="3" failed="0" error="0" timeout="0" aborted="0" inconclusive="0" passedButRunAborted="0" notRunnable="0" notExecuted="0" disconnected="0" warning="0" completed="0" inProgress="0" pending="0" />
|
||||
<Output>
|
||||
<StdOut>[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v3.1.5+1b188a7b0a (64-bit .NET 10.0.10)
|
||||
[xUnit.net 00:00:00.52] Discovering: KArtSell.ModelOperations.UnitTests
|
||||
[xUnit.net 00:00:00.58] Discovered: KArtSell.ModelOperations.UnitTests
|
||||
[xUnit.net 00:00:00.61] Starting: KArtSell.ModelOperations.UnitTests
|
||||
[xUnit.net 00:00:00.67] Finished: KArtSell.ModelOperations.UnitTests
|
||||
</StdOut>
|
||||
</Output>
|
||||
</ResultSummary>
|
||||
</TestRun>
|
||||
@@ -15,7 +15,8 @@ public sealed record ModelOperationExecutionTransition(
|
||||
ModelOperationExecutionState To,
|
||||
string ReasonCode,
|
||||
string EvidenceHash,
|
||||
DateTimeOffset OccurredAt);
|
||||
DateTimeOffset OccurredAt,
|
||||
DateTimeOffset? HoldUntil);
|
||||
|
||||
public sealed class ModelOperationExecution
|
||||
{
|
||||
@@ -48,18 +49,30 @@ public sealed class ModelOperationExecution
|
||||
public DateTimeOffset RequestedAt { get; }
|
||||
public DateTimeOffset LastOccurredAt { get; private set; }
|
||||
public ModelOperationExecutionState State { get; private set; }
|
||||
public DateTimeOffset? HoldUntil { get; private set; }
|
||||
public IReadOnlyList<ModelOperationExecutionTransition> Transitions => transitions;
|
||||
|
||||
public ModelOperationExecutionTransition MoveTo(ModelOperationExecutionState next, string reasonCode, string evidenceHash, DateTimeOffset occurredAt)
|
||||
public ModelOperationExecutionTransition MoveTo(
|
||||
ModelOperationExecutionState next,
|
||||
string reasonCode,
|
||||
string evidenceHash,
|
||||
DateTimeOffset occurredAt,
|
||||
DateTimeOffset? holdUntil = null)
|
||||
{
|
||||
if (!Allowed[State].Contains(next)) throw new InvalidOperationException($"Execution transition {State} -> {next} is not allowed.");
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(reasonCode);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(evidenceHash);
|
||||
if (occurredAt < LastOccurredAt) throw new InvalidOperationException("Execution transition time cannot move backwards.");
|
||||
var transition = new ModelOperationExecutionTransition(State, next, reasonCode, evidenceHash, occurredAt);
|
||||
if (next == ModelOperationExecutionState.BusinessHold && (!holdUntil.HasValue || holdUntil <= occurredAt))
|
||||
throw new InvalidOperationException("Business hold requires a future hold-until instant.");
|
||||
if (next != ModelOperationExecutionState.BusinessHold && holdUntil.HasValue)
|
||||
throw new InvalidOperationException("Only a business hold may have a hold-until instant.");
|
||||
|
||||
var transition = new ModelOperationExecutionTransition(State, next, reasonCode, evidenceHash, occurredAt, holdUntil);
|
||||
transitions.Add(transition);
|
||||
State = next;
|
||||
LastOccurredAt = occurredAt;
|
||||
HoldUntil = holdUntil;
|
||||
return transition;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,38 @@ public sealed class ModelOperationExecutionTests
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var execution = new ModelOperationExecution(Guid.NewGuid(), "J32:KR:1", now);
|
||||
execution.MoveTo(ModelOperationExecutionState.BusinessHold, "PIT_NOT_READY", "h1", now);
|
||||
execution.MoveTo(ModelOperationExecutionState.BusinessHold, "PIT_NOT_READY", "h1", now, now.AddMinutes(5));
|
||||
execution.MoveTo(ModelOperationExecutionState.Running, "DATA_READY", "h2", now.AddMinutes(1));
|
||||
execution.MoveTo(ModelOperationExecutionState.Succeeded, "OUTPUT_FROZEN", "h3", now.AddMinutes(2));
|
||||
Assert.Throws<InvalidOperationException>(() => execution.MoveTo(ModelOperationExecutionState.Running, "REOPEN", "h4", now.AddMinutes(3)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Business_hold_requires_a_future_expiry_but_technical_failure_cannot_set_one()
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 8, 9, 0, 0, 0, TimeSpan.Zero);
|
||||
var execution = new ModelOperationExecution(Guid.NewGuid(), "key", now);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => execution.MoveTo(ModelOperationExecutionState.BusinessHold, "PIT_GAP", "h1", now));
|
||||
|
||||
execution.MoveTo(ModelOperationExecutionState.Running, "STARTED", "h2", now.AddMinutes(1));
|
||||
Assert.Throws<InvalidOperationException>(() => execution.MoveTo(ModelOperationExecutionState.Failed, "NETWORK_TIMEOUT", "h3", now.AddMinutes(2), now.AddMinutes(3)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Business_hold_persists_reason_and_expiry_until_an_explicit_resume()
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 8, 9, 0, 0, 0, TimeSpan.Zero);
|
||||
var holdUntil = now.AddHours(1);
|
||||
var execution = new ModelOperationExecution(Guid.NewGuid(), "key", now);
|
||||
|
||||
var transition = execution.MoveTo(ModelOperationExecutionState.BusinessHold, "CALENDAR_NOT_APPROVED", "h1", now.AddMinutes(1), holdUntil);
|
||||
|
||||
Assert.Equal("CALENDAR_NOT_APPROVED", transition.ReasonCode);
|
||||
Assert.Equal(holdUntil, transition.HoldUntil);
|
||||
Assert.Equal(holdUntil, execution.HoldUntil);
|
||||
|
||||
execution.MoveTo(ModelOperationExecutionState.Running, "CALENDAR_APPROVED", "h2", holdUntil);
|
||||
Assert.Null(execution.HoldUntil);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user