diff --git a/docs/CURRENT/AEG-V15-034_CATCH_UP_POLICY_SLICE_NOTE.md b/docs/CURRENT/AEG-V15-034_CATCH_UP_POLICY_SLICE_NOTE.md
new file mode 100644
index 00000000..e3c2e2a8
--- /dev/null
+++ b/docs/CURRENT/AEG-V15-034_CATCH_UP_POLICY_SLICE_NOTE.md
@@ -0,0 +1,21 @@
+# AEG-V15-034 — Catch-up policy
+
+## Scope
+
+- **WBS ID:** `AEG-V15-034`; **Slice:** VS-18 / Scheduler
+- **Requirement / API / DB / Job / UI / Test:** `REQ-V15-SCH-02` / `V15-SCH-API` / `MIG-0020` / `J31~J40` / `Cross` / `T-V15-SCH-02`
+- **Dependency / Gate:** `AEG-V15-033` (completed, commit `6a86997`) / `G3`
+- **Acceptance:** `LATEST_ONLY`, `SKIP_MISSED`, and `ALL_WITH_LIMIT` bound recovery work so outage recovery does not create a job storm.
+
+## Source / Assumption / Unknown / Decision Required
+
+- **Source:** `contracts/schedules/model-operations.v3.json`; `DEC-071` (scheduledFor anchor); `DEC-072` (default LATEST_ONLY, no unbounded replay); `ScheduleOccurrencePlanner`; `ModelOperationsDispatcherJob`; schedule repository contract.
+- **Assumption:** `ALL_WITH_LIMIT` means dispatch at most the configured `maxCatchUp` most-recent missed anchored occurrences in one dispatcher pass. `LATEST_ONLY` dispatches only the most-recent due occurrence. `SKIP_MISSED` advances without dispatching missed occurrences.
+- **Unknown:** there is no approval to enable any schedule. This slice must remain test-only with scheduler defaults unchanged.
+- **Decision Required:** `DEC-083` (enqueue/mark atomicity) remains unresolved. The implementation retains the existing lease/release boundary and does not claim a transactional outbox solution.
+
+## Actual evidence
+
+- `dotnet test tests/KArtSell.ModelOperations.UnitTests/KArtSell.ModelOperations.UnitTests.csproj -c Release --filter FullyQualifiedName~ScheduleOccurrencePlannerTests --logger "trx;LogFileName=ScheduleOccurrencePlannerTests_20260809.trx" --results-directory evidence/AEG-V15-034`
+- Result: passed `4/4`; artifact: `evidence/AEG-V15-034/ScheduleOccurrencePlannerTests_20260809.trx`; SHA-256: `DC28BE4F2FCF511D5859B9FC3A0ADDF8CE3A566262C9848F05B06D825EA944AD`.
+- The targeted build compiled the modified dispatcher and Dapper schedule repository. No database integration test, schedule activation, market-calendar claim, or transactional enqueue/mark claim is made.
diff --git a/docs/CURRENT/CATALOGS/WBS_MASTER.csv b/docs/CURRENT/CATALOGS/WBS_MASTER.csv
index a3fcd310..dc6b413e 100644
--- a/docs/CURRENT/CATALOGS/WBS_MASTER.csv
+++ b/docs/CURRENT/CATALOGS/WBS_MASTER.csv
@@ -512,7 +512,7 @@ AEG-V15-030,S6,W13-14,Cross,Cross-cutting,UX/Layout,LAY,REQ-V15-LAY-06,Cross,Cro
AEG-V15-031,S6,W13-14,Cross,Cross-cutting,UX/Layout,LAY,REQ-V15-LAY-07,Cross,Cross,Cross,UI-V15-LAY-07,T-V15-LAY-07,반응형 운영화면 기준,responsive evidence,1024/1440/1920/모바일 업무순서 유지,UX Lead,FE/QA,4,AEG-V15-030,MVP-A,SOURCE+DESIGN_PROPOSAL,P1,PLANNED
AEG-V15-032,S6,W13-14,Cross,Cross-cutting,UX/Layout,LAY,REQ-V15-LAY-08,Cross,Cross,Cross,UI-V15-LAY-08,T-V15-LAY-08,디자인 token 변경 Gate,semantic token contract,기능 CSS 직접 색상/간격 drift 0,UX Lead,FE/QA,4,AEG-V15-031,MVP-A,SOURCE+DESIGN_PROPOSAL,P1,PLANNED
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,PLANNED
+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,PLANNED
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,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,PLANNED
diff --git a/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv b/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv
index 484f6bcd..7a83e990 100644
--- a/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv
+++ b/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv
@@ -7,6 +7,7 @@ AEG-X-005,S0,Cross,Security auth 고도화,COMPLETED,2026-08-04,"docs/decisions/
AEG-X-006,S0,Cross,Outbox publisher 고도화,COMPLETED,2026-08-04,"docs/CURRENT/ARTIFACTS/AEG-X-006_ACCEPTANCE_EVIDENCE.md + src/KArtSell.BuildingBlocks/Reliability/DapperOutboxWriter.cs + OutboxPollerJob.cs",BE/SRE,"✅ Outbox→Inbox async pipeline verified: DapperOutboxWriter (transactional), OutboxPollerJob (idempotent), DapperInboxStore (deduplication), 5 consumer implementations. Acceptance_Evidence: All criteria met. 177/177 tests PASS."
AEG-X-007,S0,Cross,Serilog/OTel correlation 고도화,COMPLETED,2026-08-06,"tests/KArtSell.ArchitectureTests/PiiRedactionTests.cs (6 tests) + commit e7913db",SRE/Security,"✅ PII redaction policy VERIFIED: SSN/Email/CreditCard/ApiKey redaction (6 tests). Commit e7913db adds pattern-based sanitization validation. All tests PASS (249/253)."
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-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-034/ScheduleOccurrencePlannerTests_20260809.trx b/evidence/AEG-V15-034/ScheduleOccurrencePlannerTests_20260809.trx
new file mode 100644
index 00000000..05d4d15d
--- /dev/null
+++ b/evidence/AEG-V15-034/ScheduleOccurrencePlannerTests_20260809.trx
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/KArtSell.Modules.ModelOperations/Application/ModelOperationsContracts.cs b/src/KArtSell.Modules.ModelOperations/Application/ModelOperationsContracts.cs
index d77acbf0..0343e5a3 100644
--- a/src/KArtSell.Modules.ModelOperations/Application/ModelOperationsContracts.cs
+++ b/src/KArtSell.Modules.ModelOperations/Application/ModelOperationsContracts.cs
@@ -54,6 +54,13 @@ public interface IModelScheduleRepository
DateTimeOffset nextDueAt,
CancellationToken cancellationToken);
+ Task AdvanceWithoutDispatchAsync(
+ Guid scheduleId,
+ string leaseOwner,
+ DateTimeOffset advancedAt,
+ DateTimeOffset nextDueAt,
+ CancellationToken cancellationToken);
+
Task ReleaseAsync(
Guid scheduleId,
string leaseOwner,
diff --git a/src/KArtSell.Modules.ModelOperations/Domain/ScheduleOccurrencePlanner.cs b/src/KArtSell.Modules.ModelOperations/Domain/ScheduleOccurrencePlanner.cs
index c3f383d8..8305c850 100644
--- a/src/KArtSell.Modules.ModelOperations/Domain/ScheduleOccurrencePlanner.cs
+++ b/src/KArtSell.Modules.ModelOperations/Domain/ScheduleOccurrencePlanner.cs
@@ -2,29 +2,56 @@ namespace KArtSell.Modules.ModelOperations.Domain;
public sealed class ScheduleOccurrencePlanner
{
+ public sealed record CatchUpPlan(
+ IReadOnlyList OccurrencesToDispatch,
+ DateTimeOffset NextDueAt);
+
public static DateTimeOffset GetNextDueAt(
DateTimeOffset scheduledFor,
string cadence,
string catchUpPolicy,
int maxCatchUp,
DateTimeOffset now)
+ => Plan(scheduledFor, cadence, catchUpPolicy, maxCatchUp, now).NextDueAt;
+
+ public static CatchUpPlan Plan(
+ DateTimeOffset scheduledFor,
+ string cadence,
+ string catchUpPolicy,
+ int maxCatchUp,
+ DateTimeOffset now)
{
if (maxCatchUp is < 0 or > 31) throw new ArgumentOutOfRangeException(nameof(maxCatchUp));
- var next = Advance(scheduledFor, cadence);
+
+ var due = GetDueOccurrences(scheduledFor, cadence, now);
+ var nextDueAt = due.Count == 0 ? scheduledFor : Advance(due[^1], cadence);
+
+ if (catchUpPolicy.Equals("LATEST_ONLY", StringComparison.OrdinalIgnoreCase))
+ return new CatchUpPlan(due.Count == 0 ? [scheduledFor] : [due[^1]], nextDueAt);
+
+ if (catchUpPolicy.Equals("SKIP_MISSED", StringComparison.OrdinalIgnoreCase))
+ return new CatchUpPlan(scheduledFor < now ? [] : [scheduledFor], nextDueAt);
+
if (catchUpPolicy.Equals("ALL_WITH_LIMIT", StringComparison.OrdinalIgnoreCase))
- return next;
+ return new CatchUpPlan(due.Count <= maxCatchUp ? due : due.TakeLast(maxCatchUp).ToArray(), nextDueAt);
- if (!catchUpPolicy.Equals("LATEST_ONLY", StringComparison.OrdinalIgnoreCase)
- && !catchUpPolicy.Equals("SKIP_MISSED", StringComparison.OrdinalIgnoreCase))
- throw new InvalidOperationException($"Unsupported catch-up policy '{catchUpPolicy}'.");
+ throw new InvalidOperationException($"Unsupported catch-up policy '{catchUpPolicy}'.");
+ }
+ private static IReadOnlyList GetDueOccurrences(DateTimeOffset scheduledFor, string cadence, DateTimeOffset now)
+ {
+ if (scheduledFor > now) return [];
+
+ var due = new List();
+ var occurrence = scheduledFor;
var guard = 0;
- while (next <= now)
+ while (occurrence <= now)
{
- next = Advance(next, cadence);
+ due.Add(occurrence);
+ occurrence = Advance(occurrence, cadence);
if (++guard > 5000) throw new InvalidOperationException("Schedule catch-up exceeded safety guard.");
}
- return next;
+ return due;
}
private static DateTimeOffset Advance(DateTimeOffset value, string cadence) => cadence.ToUpperInvariant() switch
diff --git a/src/KArtSell.Modules.ModelOperations/Infrastructure/DapperModelScheduleRepository.cs b/src/KArtSell.Modules.ModelOperations/Infrastructure/DapperModelScheduleRepository.cs
index 753bc744..34fb2085 100644
--- a/src/KArtSell.Modules.ModelOperations/Infrastructure/DapperModelScheduleRepository.cs
+++ b/src/KArtSell.Modules.ModelOperations/Infrastructure/DapperModelScheduleRepository.cs
@@ -59,6 +59,16 @@ public sealed class DapperModelScheduleRepository(IDbConnectionFactory connectio
where schedule_id = @ScheduleId and lease_owner = @LeaseOwner;
""";
+ private const string AdvanceSql = """
+ update evaluation.model_operation_schedule
+ set next_due_at = @NextDueAt,
+ lease_owner = null,
+ lease_until = null,
+ last_error_code = null,
+ updated_at = @AdvancedAt
+ where schedule_id = @ScheduleId and lease_owner = @LeaseOwner;
+ """;
+
public async Task> AcquireDueAsync(DateTimeOffset now, string leaseOwner, TimeSpan leaseDuration, int limit, CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
@@ -73,6 +83,13 @@ public sealed class DapperModelScheduleRepository(IDbConnectionFactory connectio
if (affected != 1) throw new InvalidOperationException("Schedule lease was lost before dispatch completion.");
}
+ public async Task AdvanceWithoutDispatchAsync(Guid scheduleId, string leaseOwner, DateTimeOffset advancedAt, DateTimeOffset nextDueAt, CancellationToken cancellationToken)
+ {
+ await using var connection = await connectionFactory.OpenAsync(cancellationToken);
+ var affected = await connection.ExecuteAsync(new CommandDefinition(AdvanceSql, new { ScheduleId = scheduleId, LeaseOwner = leaseOwner, AdvancedAt = advancedAt, NextDueAt = nextDueAt }, cancellationToken: cancellationToken));
+ if (affected != 1) throw new InvalidOperationException("Schedule lease was lost before advance completion.");
+ }
+
public async Task ReleaseAsync(Guid scheduleId, string leaseOwner, string reasonCode, DateTimeOffset releasedAt, CancellationToken cancellationToken)
{
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
diff --git a/src/KArtSell.Modules.ModelOperations/Scheduling/ModelOperationsDispatcherJob.cs b/src/KArtSell.Modules.ModelOperations/Scheduling/ModelOperationsDispatcherJob.cs
index 57950a1c..c3c0acbc 100644
--- a/src/KArtSell.Modules.ModelOperations/Scheduling/ModelOperationsDispatcherJob.cs
+++ b/src/KArtSell.Modules.ModelOperations/Scheduling/ModelOperationsDispatcherJob.cs
@@ -33,21 +33,32 @@ public sealed class ModelOperationsDispatcherJob(
{
try
{
- var job = Job.FromExpression(handler => handler.ExecuteAsync(
- item.ScheduleId,
- item.OperationCode,
- item.ScopeKey,
- item.AutomationMode,
- item.IdempotencyKey));
- var backgroundJobId = jobs.Create(job, new EnqueuedState(item.Queue));
+ var plan = ScheduleOccurrencePlanner.Plan(item.ScheduledFor, item.Cadence, item.CatchUpPolicy, item.MaxCatchUp, now);
+ if (plan.OccurrencesToDispatch.Count == 0)
+ {
+ await schedules.AdvanceWithoutDispatchAsync(item.ScheduleId, leaseOwner, now, plan.NextDueAt, CancellationToken.None);
+ continue;
+ }
+
+ var backgroundJobId = string.Empty;
+ foreach (var occurrence in plan.OccurrencesToDispatch)
+ {
+ var idempotencyKey = $"{item.OperationCode}:{item.ScopeKey}:{item.ScheduleVersion}:{occurrence.UtcDateTime:yyyyMMddHHmmss}Z";
+ var job = Job.FromExpression(handler => handler.ExecuteAsync(
+ item.ScheduleId,
+ item.OperationCode,
+ item.ScopeKey,
+ item.AutomationMode,
+ idempotencyKey));
+ backgroundJobId = jobs.Create(job, new EnqueuedState(item.Queue));
+ }
- var nextDueAt = ScheduleOccurrencePlanner.GetNextDueAt(item.ScheduledFor, item.Cadence, item.CatchUpPolicy, item.MaxCatchUp, now);
await schedules.MarkDispatchedAsync(
item.ScheduleId,
leaseOwner,
backgroundJobId,
now,
- nextDueAt,
+ plan.NextDueAt,
CancellationToken.None);
}
catch (Exception exception)
diff --git a/tests/KArtSell.ModelOperations.UnitTests/ScheduleOccurrencePlannerTests.cs b/tests/KArtSell.ModelOperations.UnitTests/ScheduleOccurrencePlannerTests.cs
index 50e2946d..6eeac2ee 100644
--- a/tests/KArtSell.ModelOperations.UnitTests/ScheduleOccurrencePlannerTests.cs
+++ b/tests/KArtSell.ModelOperations.UnitTests/ScheduleOccurrencePlannerTests.cs
@@ -15,4 +15,22 @@ public sealed class ScheduleOccurrencePlannerTests
Assert.True(next > new DateTimeOffset(2026, 8, 1, 4, 0, 0, TimeSpan.Zero));
Assert.Equal(1, next.Hour);
}
+
+ [Fact] public void All_with_limit_dispatches_only_the_most_recent_configured_occurrences()
+ {
+ var scheduled = new DateTimeOffset(2026, 8, 1, 1, 0, 0, TimeSpan.Zero);
+ var plan = ScheduleOccurrencePlanner.Plan(scheduled, "DAILY", "ALL_WITH_LIMIT", 2, new DateTimeOffset(2026, 8, 4, 4, 0, 0, TimeSpan.Zero));
+
+ Assert.Equal([scheduled.AddDays(2), scheduled.AddDays(3)], plan.OccurrencesToDispatch);
+ Assert.Equal(scheduled.AddDays(4), plan.NextDueAt);
+ }
+
+ [Fact] public void Skip_missed_advances_without_dispatching_a_stale_occurrence()
+ {
+ var scheduled = new DateTimeOffset(2026, 8, 1, 1, 0, 0, TimeSpan.Zero);
+ var plan = ScheduleOccurrencePlanner.Plan(scheduled, "DAILY", "SKIP_MISSED", 1, new DateTimeOffset(2026, 8, 3, 4, 0, 0, TimeSpan.Zero));
+
+ Assert.Empty(plan.OccurrencesToDispatch);
+ Assert.Equal(scheduled.AddDays(3), plan.NextDueAt);
+ }
}