diff --git a/docs/CURRENT/AEG-V15-036_DISPATCH_CAS_SLICE_NOTE.md b/docs/CURRENT/AEG-V15-036_DISPATCH_CAS_SLICE_NOTE.md
index a8d41e5c..24925e34 100644
--- a/docs/CURRENT/AEG-V15-036_DISPATCH_CAS_SLICE_NOTE.md
+++ b/docs/CURRENT/AEG-V15-036_DISPATCH_CAS_SLICE_NOTE.md
@@ -18,4 +18,6 @@
- `dotnet test tests/KArtSell.ModelOperations.UnitTests/KArtSell.ModelOperations.UnitTests.csproj -c Release --filter "FullyQualifiedName~DapperModelScheduleRepositoryContractTests|FullyQualifiedName~ScheduleOccurrencePlannerTests|FullyQualifiedName~ModelOperationRequestServiceTests" --logger "trx;LogFileName=DispatcherCasContractTests_20260809.trx" --results-directory evidence/AEG-V15-036`
- Result: passed `8/8`; artifact: `evidence/AEG-V15-036/DispatcherCasContractTests_20260809.trx`; SHA-256: `148FF7904CAE7372DD4728C24B845E280CEEE3116E5787BE621EC8E2E9005921`.
- The test locks the repository contract: dispatched, no-dispatch advance, and release SQL must all match both the lease owner and the acquired `dispatch_revision`.
-- **Not complete:** a PostgreSQL concurrency test must acquire/reacquire the same schedule and show the stale revision updates zero rows. Until that artifact exists, the WBS remains `IN_PROGRESS`.
+- **PostgreSQL integration:** `dotnet test tests/KArtSell.Integration.Tests/KArtSell.Integration.Tests.csproj -c Release --filter FullyQualifiedName~ModelScheduleCasTests --no-restore --logger "trx;LogFileName=ModelScheduleCasTests_20260809.trx" --results-directory evidence/AEG-V15-036` passed `1/1`; artifact SHA-256: `49627FF0180034D2A7A1E4393448C73D337D918E7CE47EA9FC2BDB144FBBA833`.
+- The test inserts one isolated test-db schedule, acquires it twice after expiring the first lease, and proves the stale owner/revision cannot advance it while the reacquired owner/revision and original `next_due_at` remain intact. It cleans up that row in `finally`.
+- The first integration execution exposed a real Dapper positional-record materialization failure. `DapperModelScheduleRepository` now maps a private SQL row DTO explicitly to the immutable `DueModelOperation` contract; the successful rerun is the preserved evidence.
diff --git a/docs/CURRENT/CATALOGS/WBS_MASTER.csv b/docs/CURRENT/CATALOGS/WBS_MASTER.csv
index 26e048ff..ab52bf62 100644
--- a/docs/CURRENT/CATALOGS/WBS_MASTER.csv
+++ b/docs/CURRENT/CATALOGS/WBS_MASTER.csv
@@ -514,7 +514,7 @@ AEG-V15-032,S6,W13-14,Cross,Cross-cutting,UX/Layout,LAY,REQ-V15-LAY-08,Cross,Cro
AEG-V15-033,S8,W17-18,VS-18,BacktestingEvaluation,Scheduler,SCH,REQ-V15-SCH-01,V15-SCH-API,MIG-0020,J31~J40,Cross,T-V15-SCH-01,Schedule anchor 계산기,ScheduleOccurrencePlanner,dispatch 지연이 cadence drift를 만들지 않음,BE Lead,SRE/QA,3,AEG-X-001,G3,SOURCE+DESIGN_PROPOSAL,P0,COMPLETED
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,IN_PROGRESS
+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-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
diff --git a/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv b/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv
index 8687c907..c94ea086 100644
--- a/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv
+++ b/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv
@@ -9,7 +9,7 @@ AEG-X-007,S0,Cross,Serilog/OTel correlation 고도화,COMPLETED,2026-08-06,"test
AEG-V15-033,S8,VS-18,Schedule anchor 계산기,COMPLETED,2026-08-09,"docs/CURRENT/AEG-V15-033_SCHEDULE_ANCHOR_SLICE_NOTE.md; src/KArtSell.Modules.ModelOperations/Domain/ScheduleOccurrencePlanner.cs; tests/KArtSell.ModelOperations.UnitTests/ScheduleOccurrencePlannerTests.cs; evidence/AEG-V15-033/ScheduleOccurrencePlannerTests_20260809.trx",BE Lead,"Actual Release run: dotnet test tests/KArtSell.ModelOperations.UnitTests/KArtSell.ModelOperations.UnitTests.csproj -c Release --filter FullyQualifiedName~ScheduleOccurrencePlannerTests; 2/2 passed. Characterizes the approved scheduledFor anchor and missed-occurrence skip behavior. No schedule was enabled; market-calendar/timezone (DEC-079), dispatch enqueue/mark atomicity (DEC-083), leases, database integration, and later scheduler WBS evidence remain out of scope."
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,IN_PROGRESS,TBD,"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; evidence/AEG-V15-036/DispatcherCasContractTests_20260809.trx",BE Lead,"Implemented acquired dispatch_revision as an optimistic CAS token across dispatched, no-dispatch advance, and release paths; dispatcher passes the acquired revision. Actual targeted Release evidence: 8/8 passed. Remains IN_PROGRESS: PostgreSQL concurrent lease-loss/reacquire integration proof (stale revision affects 0 rows) is required before Acceptance_Evidence 'duplicate advance 0' can be claimed. Schedules remain disabled; DEC-083 remains unresolved."
+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-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-036/ModelScheduleCasTests_20260809.trx b/evidence/AEG-V15-036/ModelScheduleCasTests_20260809.trx
new file mode 100644
index 00000000..e59d61fc
--- /dev/null
+++ b/evidence/AEG-V15-036/ModelScheduleCasTests_20260809.trx
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/KArtSell.Modules.ModelOperations/Infrastructure/DapperModelScheduleRepository.cs b/src/KArtSell.Modules.ModelOperations/Infrastructure/DapperModelScheduleRepository.cs
index a971eb99..415a959e 100644
--- a/src/KArtSell.Modules.ModelOperations/Infrastructure/DapperModelScheduleRepository.cs
+++ b/src/KArtSell.Modules.ModelOperations/Infrastructure/DapperModelScheduleRepository.cs
@@ -79,8 +79,20 @@ public sealed class DapperModelScheduleRepository(IDbConnectionFactory connectio
public async Task> AcquireDueAsync(DateTimeOffset now, string leaseOwner, TimeSpan leaseDuration, int limit, CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
- var items = await connection.QueryAsync(new CommandDefinition(AcquireSql, new { Now = now, LeaseOwner = leaseOwner, LeaseUntil = now.Add(leaseDuration), Limit = limit }, cancellationToken: cancellationToken));
- return items.AsList();
+ var rows = await connection.QueryAsync(new CommandDefinition(AcquireSql, new { Now = now, LeaseOwner = leaseOwner, LeaseUntil = now.Add(leaseDuration), Limit = limit }, cancellationToken: cancellationToken));
+ return rows.Select(row => new DueModelOperation(
+ row.ScheduleId,
+ row.OperationCode,
+ row.ScopeKey,
+ row.Cadence,
+ row.AutomationMode,
+ row.Queue,
+ row.IdempotencyKey,
+ row.ScheduleVersion,
+ row.DispatchRevision,
+ row.ScheduledFor,
+ row.CatchUpPolicy,
+ row.MaxCatchUp)).ToArray();
}
public async Task MarkDispatchedAsync(Guid scheduleId, string leaseOwner, int expectedDispatchRevision, string backgroundJobId, DateTimeOffset dispatchedAt, DateTimeOffset nextDueAt, CancellationToken cancellationToken)
@@ -102,4 +114,20 @@ public sealed class DapperModelScheduleRepository(IDbConnectionFactory connectio
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
await connection.ExecuteAsync(new CommandDefinition(ReleaseSql, new { ScheduleId = scheduleId, LeaseOwner = leaseOwner, ExpectedDispatchRevision = expectedDispatchRevision, ReasonCode = reasonCode, ReleasedAt = releasedAt }, cancellationToken: cancellationToken));
}
+
+ private sealed class DueScheduleRow
+ {
+ public Guid ScheduleId { get; init; }
+ public string OperationCode { get; init; } = string.Empty;
+ public string ScopeKey { get; init; } = string.Empty;
+ public string Cadence { get; init; } = string.Empty;
+ public string AutomationMode { get; init; } = string.Empty;
+ public string Queue { get; init; } = string.Empty;
+ public string IdempotencyKey { get; init; } = string.Empty;
+ public int ScheduleVersion { get; init; }
+ public int DispatchRevision { get; init; }
+ public DateTimeOffset ScheduledFor { get; init; }
+ public string CatchUpPolicy { get; init; } = string.Empty;
+ public int MaxCatchUp { get; init; }
+ }
}
diff --git a/tests/KArtSell.Integration.Tests/Scheduling/ModelScheduleCasTests.cs b/tests/KArtSell.Integration.Tests/Scheduling/ModelScheduleCasTests.cs
new file mode 100644
index 00000000..85b9dd90
--- /dev/null
+++ b/tests/KArtSell.Integration.Tests/Scheduling/ModelScheduleCasTests.cs
@@ -0,0 +1,78 @@
+using Dapper;
+using KArtSell.BuildingBlocks.Data;
+using KArtSell.Modules.ModelOperations.Infrastructure;
+using Npgsql;
+using Xunit;
+
+namespace KArtSell.Integration.Tests.Scheduling;
+
+[Collection("Database")]
+public sealed class ModelScheduleCasTests : IAsyncLifetime
+{
+ private readonly NpgsqlDataSource _dataSource = new NpgsqlDataSourceBuilder(TestDatabaseConnection.GetConnectionString()).Build();
+
+ public async Task InitializeAsync()
+ {
+ await using var connection = await _dataSource.OpenConnectionAsync();
+ await connection.ExecuteAsync("select 1");
+ }
+
+ public Task DisposeAsync() => _dataSource.DisposeAsync().AsTask();
+
+ [Fact]
+ public async Task Stale_dispatch_revision_cannot_advance_a_reacquired_schedule()
+ {
+ var scheduleId = Guid.NewGuid();
+ var scheduledFor = new DateTimeOffset(2026, 8, 9, 0, 0, 0, TimeSpan.Zero);
+ const string firstOwner = "cas-test:first";
+ const string secondOwner = "cas-test:second";
+ var repository = new DapperModelScheduleRepository(new NpgsqlConnectionFactory(_dataSource));
+
+ try
+ {
+ await using (var connection = await _dataSource.OpenConnectionAsync())
+ {
+ await connection.ExecuteAsync("""
+ insert into evaluation.model_operation_schedule
+ (schedule_id, operation_code, operation_name, scope_key, cadence, automation_mode,
+ queue_name, schedule_version, enabled, next_due_at, max_lag, primary_owner, secondary_owner)
+ values
+ (@ScheduleId, 'J31', 'CasTest', @ScopeKey, 'DAILY', 'EVALUATION_ONLY',
+ 'q-control', 1, true, @ScheduledFor, interval '1 hour', 'BE', 'QA');
+ """, new { ScheduleId = scheduleId, ScopeKey = $"CAS-{scheduleId:N}", ScheduledFor = scheduledFor });
+ }
+
+ var first = Assert.Single(await repository.AcquireDueAsync(scheduledFor.AddMinutes(1), firstOwner, TimeSpan.FromMinutes(5), 1, CancellationToken.None));
+
+ await using (var connection = await _dataSource.OpenConnectionAsync())
+ {
+ await connection.ExecuteAsync("""
+ update evaluation.model_operation_schedule
+ set lease_until = @ExpiredAt
+ where schedule_id = @ScheduleId;
+ """, new { ScheduleId = scheduleId, ExpiredAt = scheduledFor });
+ }
+
+ var second = Assert.Single(await repository.AcquireDueAsync(scheduledFor.AddMinutes(2), secondOwner, TimeSpan.FromMinutes(5), 1, CancellationToken.None));
+ Assert.True(second.DispatchRevision > first.DispatchRevision);
+
+ await Assert.ThrowsAsync(() => repository.MarkDispatchedAsync(
+ scheduleId, firstOwner, first.DispatchRevision, "stale-job", scheduledFor.AddMinutes(3), scheduledFor.AddDays(1), CancellationToken.None));
+
+ await using var verify = await _dataSource.OpenConnectionAsync();
+ var row = await verify.QuerySingleAsync<(DateTimeOffset NextDueAt, string LeaseOwner, int DispatchRevision)>("""
+ select next_due_at as NextDueAt, lease_owner as LeaseOwner, dispatch_revision as DispatchRevision
+ from evaluation.model_operation_schedule
+ where schedule_id = @ScheduleId;
+ """, new { ScheduleId = scheduleId });
+ Assert.Equal(scheduledFor, row.NextDueAt);
+ Assert.Equal(secondOwner, row.LeaseOwner);
+ Assert.Equal(second.DispatchRevision, row.DispatchRevision);
+ }
+ finally
+ {
+ await using var cleanup = await _dataSource.OpenConnectionAsync();
+ await cleanup.ExecuteAsync("delete from evaluation.model_operation_schedule where schedule_id = @ScheduleId", new { ScheduleId = scheduleId });
+ }
+ }
+}