diff --git a/docs/CURRENT/AEG-V15-038_HEARTBEAT_AGING_SLICE_NOTE.md b/docs/CURRENT/AEG-V15-038_HEARTBEAT_AGING_SLICE_NOTE.md
new file mode 100644
index 00000000..7f097fee
--- /dev/null
+++ b/docs/CURRENT/AEG-V15-038_HEARTBEAT_AGING_SLICE_NOTE.md
@@ -0,0 +1,21 @@
+# AEG-V15-038 — Schedule heartbeat and aging
+
+## Scope
+
+- **WBS ID:** `AEG-V15-038`; **Requirement / API / DB / Job / UI / Test:** `REQ-V15-SCH-06` / `V15-SCH-API` / `MIG-0020` / `J31~J40` / `Cross` / `T-V15-SCH-06`.
+- **Dependency / Gate:** `AEG-V15-037` (completed, commit `d38dc32`) / `G3`.
+- **Acceptance:** detect a stuck request and produce Owner-alert evidence.
+
+## Source / Assumption / Unknown / Decision Required
+
+- **Source:** `MIG-0020` provides `last_heartbeat_at`; the execution state machine has no heartbeat contract; schedule rows provide Primary/Secondary owners.
+- **Assumption:** a heartbeat is valid only for a running execution and cannot move time backwards. Staleness is evaluated against a caller-supplied cutoff, not a hidden default threshold.
+- **Unknown:** no approved stale duration, alert transport, severity, or escalation policy exists for J31–J40.
+- **Decision Required:** an approved owner-alert contract is required before an I/O alert sender or automatic action may be added. This Slice deliberately creates no default threshold, alert, or schedule activation.
+
+## Actual evidence and remaining acceptance
+
+- `dotnet test tests/KArtSell.ModelOperations.UnitTests/KArtSell.ModelOperations.UnitTests.csproj -c Release --filter FullyQualifiedName~ModelOperationExecutionTests --logger "trx;LogFileName=ModelOperationExecutionHeartbeatTests_20260809.trx" --results-directory evidence/AEG-V15-038`
+- Result: passed `5/5`; artifact: `evidence/AEG-V15-038/ModelOperationExecutionHeartbeatTests_20260809.trx`; SHA-256: `2ADBB526FAF6E5D924EB3F53C7E582E736199A59E0DA4E4FD25DCAA82A661BBC`.
+- A running execution alone can write a monotonic heartbeat; a caller-provided cutoff deterministically identifies staleness. No magic number, wall clock, alert sender, retry, or activation was introduced.
+- **Not complete:** persist heartbeat to `last_heartbeat_at`, query it with schedule owners, and produce Owner-alert evidence after the stale-duration and alert-contract decision is approved.
diff --git a/docs/CURRENT/CATALOGS/WBS_MASTER.csv b/docs/CURRENT/CATALOGS/WBS_MASTER.csv
index 6f95be14..db375e01 100644
--- a/docs/CURRENT/CATALOGS/WBS_MASTER.csv
+++ b/docs/CURRENT/CATALOGS/WBS_MASTER.csv
@@ -516,7 +516,7 @@ AEG-V15-034,S8,W17-18,VS-18,BacktestingEvaluation,Scheduler,SCH,REQ-V15-SCH-02,V
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,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-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,IN_PROGRESS
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
AEG-V15-041,S8,W17-18,VS-18,BacktestingEvaluation,Evaluation Windows,EVA,REQ-V15-EVA-01,V15-EVA-API,MIG-0020,J31~J40,Cross,T-V15-EVA-01,1/5/20/63/126/252 window planner,EvaluationWindowPlanner,calendar day가 아닌 거래세션 사용,Quant Lead,Data/QA,3,AEG-X-001,G4,SOURCE+DESIGN_PROPOSAL,P0,PLANNED
diff --git a/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv b/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv
index 3b95dc7e..9ebff0ff 100644
--- a/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv
+++ b/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv
@@ -11,6 +11,7 @@ AEG-V15-034,S8,VS-18,Catch-up policy 구현,COMPLETED,2026-08-09,"docs/CURRENT/A
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-V15-038,S8,VS-18,Schedule heartbeat/aging,IN_PROGRESS,TBD,"docs/CURRENT/AEG-V15-038_HEARTBEAT_AGING_SLICE_NOTE.md; src/KArtSell.Modules.ModelOperations/Domain/ModelOperationExecution.cs; tests/KArtSell.ModelOperations.UnitTests/ModelOperationExecutionTests.cs; evidence/AEG-V15-038/ModelOperationExecutionHeartbeatTests_20260809.trx",BE Lead,"Implemented and verified the pure heartbeat/aging contract: only RUNNING accepts monotonic heartbeats, and staleness uses an explicit caller-supplied cutoff (5/5 targeted Release tests passed). Still IN_PROGRESS: the approved stale-duration, alert channel/owner/escalation contract is absent, so no magic timeout, alert sender, persistence workflow, or schedule activation was invented."
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."
diff --git a/evidence/AEG-V15-038/ModelOperationExecutionHeartbeatTests_20260809.trx b/evidence/AEG-V15-038/ModelOperationExecutionHeartbeatTests_20260809.trx
new file mode 100644
index 00000000..9cf230e2
--- /dev/null
+++ b/evidence/AEG-V15-038/ModelOperationExecutionHeartbeatTests_20260809.trx
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/KArtSell.Modules.ModelOperations/Domain/ModelOperationExecution.cs b/src/KArtSell.Modules.ModelOperations/Domain/ModelOperationExecution.cs
index c9eda108..707d4f16 100644
--- a/src/KArtSell.Modules.ModelOperations/Domain/ModelOperationExecution.cs
+++ b/src/KArtSell.Modules.ModelOperations/Domain/ModelOperationExecution.cs
@@ -48,6 +48,7 @@ public sealed class ModelOperationExecution
public string IdempotencyKey { get; }
public DateTimeOffset RequestedAt { get; }
public DateTimeOffset LastOccurredAt { get; private set; }
+ public DateTimeOffset? LastHeartbeatAt { get; private set; }
public ModelOperationExecutionState State { get; private set; }
public DateTimeOffset? HoldUntil { get; private set; }
public IReadOnlyList Transitions => transitions;
@@ -73,8 +74,28 @@ public sealed class ModelOperationExecution
State = next;
LastOccurredAt = occurredAt;
HoldUntil = holdUntil;
+ if (next == ModelOperationExecutionState.Running) LastHeartbeatAt = occurredAt;
return transition;
}
+ public void RecordHeartbeat(DateTimeOffset occurredAt)
+ {
+ if (State != ModelOperationExecutionState.Running)
+ throw new InvalidOperationException("Only a running execution may record a heartbeat.");
+ if (occurredAt < LastHeartbeatAt)
+ throw new InvalidOperationException("Execution heartbeat time cannot move backwards.");
+
+ LastHeartbeatAt = occurredAt;
+ }
+
+ public bool HasExceededHeartbeatCutoff(DateTimeOffset asOf, TimeSpan allowedSilence)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(allowedSilence, TimeSpan.Zero);
+ if (asOf < LastHeartbeatAt) throw new InvalidOperationException("Heartbeat cutoff cannot precede the latest heartbeat.");
+ return State == ModelOperationExecutionState.Running
+ && LastHeartbeatAt.HasValue
+ && asOf - LastHeartbeatAt.Value > allowedSilence;
+ }
+
private static HashSet Set(params ModelOperationExecutionState[] states) => new(states);
}
diff --git a/tests/KArtSell.ModelOperations.UnitTests/ModelOperationExecutionTests.cs b/tests/KArtSell.ModelOperations.UnitTests/ModelOperationExecutionTests.cs
index c36e0bce..4a1c06b0 100644
--- a/tests/KArtSell.ModelOperations.UnitTests/ModelOperationExecutionTests.cs
+++ b/tests/KArtSell.ModelOperations.UnitTests/ModelOperationExecutionTests.cs
@@ -40,4 +40,26 @@ public sealed class ModelOperationExecutionTests
execution.MoveTo(ModelOperationExecutionState.Running, "CALENDAR_APPROVED", "h2", holdUntil);
Assert.Null(execution.HoldUntil);
}
+
+ [Fact]
+ public void Running_execution_records_monotonic_heartbeats_and_uses_the_caller_cutoff()
+ {
+ var now = new DateTimeOffset(2026, 8, 9, 0, 0, 0, TimeSpan.Zero);
+ var execution = new ModelOperationExecution(Guid.NewGuid(), "key", now);
+ execution.MoveTo(ModelOperationExecutionState.Running, "STARTED", "h1", now);
+ execution.RecordHeartbeat(now.AddMinutes(2));
+
+ Assert.False(execution.HasExceededHeartbeatCutoff(now.AddMinutes(6), TimeSpan.FromMinutes(5)));
+ Assert.True(execution.HasExceededHeartbeatCutoff(now.AddMinutes(8), TimeSpan.FromMinutes(5)));
+ Assert.Throws(() => execution.RecordHeartbeat(now.AddMinutes(1)));
+ }
+
+ [Fact]
+ public void Non_running_execution_cannot_record_a_heartbeat()
+ {
+ var now = new DateTimeOffset(2026, 8, 9, 0, 0, 0, TimeSpan.Zero);
+ var execution = new ModelOperationExecution(Guid.NewGuid(), "key", now);
+
+ Assert.Throws(() => execution.RecordHeartbeat(now));
+ }
}