From d18f6a7a677653d9a68d2263fb18a3c1170de47e Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Sun, 9 Aug 2026 02:04:26 +0900 Subject: [PATCH] feat: preserve due operation provenance (AEG-V15-035) Carries scheduledFor, catch-up policy, and maxCatchUp from the scheduler through the request model and transactional outbox. Evidence: targeted Release tests 5/5 passed; TRX SHA256 C1BF3EF274702305A29673D5B6A1C3A98D08B1716DA3CD8CB0EE710B5E6C12E6. Schedules remain disabled. --- ...5-035_DUE_OPERATION_CONTRACT_SLICE_NOTE.md | 21 +++++++ docs/CURRENT/CATALOGS/WBS_MASTER.csv | 2 +- .../CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv | 1 + ...ueModelOperationContractTests_20260809.trx | 58 +++++++++++++++++++ .../ModelOperationRequestService.cs | 8 +++ .../Application/ModelOperationsContracts.cs | 6 ++ .../DapperModelOperationRequestRepository.cs | 8 ++- .../ModelOperationsDispatcherJob.cs | 5 +- .../Scheduling/ScheduledModelOperationJob.cs | 8 ++- .../ModelOperationRequestServiceTests.cs | 56 ++++++++++++++++++ 10 files changed, 168 insertions(+), 5 deletions(-) create mode 100644 docs/CURRENT/AEG-V15-035_DUE_OPERATION_CONTRACT_SLICE_NOTE.md create mode 100644 evidence/AEG-V15-035/DueModelOperationContractTests_20260809.trx create mode 100644 tests/KArtSell.ModelOperations.UnitTests/ModelOperationRequestServiceTests.cs diff --git a/docs/CURRENT/AEG-V15-035_DUE_OPERATION_CONTRACT_SLICE_NOTE.md b/docs/CURRENT/AEG-V15-035_DUE_OPERATION_CONTRACT_SLICE_NOTE.md new file mode 100644 index 00000000..cc331b53 --- /dev/null +++ b/docs/CURRENT/AEG-V15-035_DUE_OPERATION_CONTRACT_SLICE_NOTE.md @@ -0,0 +1,21 @@ +# AEG-V15-035 — Due operation contract + +## Scope + +- **WBS ID:** `AEG-V15-035`; **Slice:** VS-18 / Scheduler +- **Requirement / API / DB / Job / UI / Test:** `REQ-V15-SCH-03` / `V15-SCH-API` / `MIG-0020` / `J31~J40` / `Cross` / `T-V15-SCH-03` +- **Dependency / Gate:** `AEG-V15-034` (completed, commit `dd35259`) / `G3` +- **Acceptance:** persist and trace `scheduledFor`, catch-up policy, and `maxCatchUp` for each model-operation request. + +## Source / Assumption / Unknown / Decision Required + +- **Source:** `db/migrations/0020_v15_execution_and_ui_contract_hardening.sql` adds nullable `evaluation.model_operation_request.scheduled_for`; `DueModelOperation` already receives `ScheduledFor`, `CatchUpPolicy`, and `MaxCatchUp`; the dispatcher and request repository currently drop this provenance before insert. +- **Assumption:** an individual request must preserve the selected occurrence's UTC scheduled time and the policy/configuration that selected it. These are immutable request provenance, not mutable schedule state. +- **Unknown:** `scheduled_for` is nullable in the approved migration. This Slice will populate it for scheduler-created requests but will not change historical rows or alter the immutable migration. +- **Decision Required:** a full per-occurrence dispatch ledger and enqueue/mark transactional outbox remain `DEC-083` / later scheduler work; this Slice only closes the currently missing request-level provenance. + +## Actual evidence + +- `dotnet test tests/KArtSell.ModelOperations.UnitTests/KArtSell.ModelOperations.UnitTests.csproj -c Release --filter "FullyQualifiedName~ScheduleOccurrencePlannerTests|FullyQualifiedName~ModelOperationRequestServiceTests" --logger "trx;LogFileName=DueModelOperationContractTests_20260809.trx" --results-directory evidence/AEG-V15-035` +- Result: passed `5/5`; artifact: `evidence/AEG-V15-035/DueModelOperationContractTests_20260809.trx`; SHA-256: `C1BF3EF274702305A29673D5B6A1C3A98D08B1716DA3CD8CB0EE710B5E6C12E6`. +- The targeted build compiles Hangfire serialization, application validation, the append-only request insert, transactional outbox payload, and the scheduler policy tests. No schedule was enabled and no database migration or PostgreSQL test is claimed. diff --git a/docs/CURRENT/CATALOGS/WBS_MASTER.csv b/docs/CURRENT/CATALOGS/WBS_MASTER.csv index dc6b413e..e31c538b 100644 --- a/docs/CURRENT/CATALOGS/WBS_MASTER.csv +++ b/docs/CURRENT/CATALOGS/WBS_MASTER.csv @@ -513,7 +513,7 @@ AEG-V15-031,S6,W13-14,Cross,Cross-cutting,UX/Layout,LAY,REQ-V15-LAY-07,Cross,Cro 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,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-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,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 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 diff --git a/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv b/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv index 7a83e990..5defd335 100644 --- a/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv +++ b/docs/CURRENT/CATALOGS/WBS_PROGRESS_TRACKER.csv @@ -8,6 +8,7 @@ AEG-X-006,S0,Cross,Outbox publisher 고도화,COMPLETED,2026-08-04,"docs/CURRENT 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-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-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-035/DueModelOperationContractTests_20260809.trx b/evidence/AEG-V15-035/DueModelOperationContractTests_20260809.trx new file mode 100644 index 00000000..714192bc --- /dev/null +++ b/evidence/AEG-V15-035/DueModelOperationContractTests_20260809.trx @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + [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.50] Discovering: KArtSell.ModelOperations.UnitTests +[xUnit.net 00:00:00.55] Discovered: KArtSell.ModelOperations.UnitTests +[xUnit.net 00:00:00.58] Starting: KArtSell.ModelOperations.UnitTests +[xUnit.net 00:00:00.64] Finished: KArtSell.ModelOperations.UnitTests + + + + \ No newline at end of file diff --git a/src/KArtSell.Modules.ModelOperations/Application/ModelOperationRequestService.cs b/src/KArtSell.Modules.ModelOperations/Application/ModelOperationRequestService.cs index 078fa410..b0975105 100644 --- a/src/KArtSell.Modules.ModelOperations/Application/ModelOperationRequestService.cs +++ b/src/KArtSell.Modules.ModelOperations/Application/ModelOperationRequestService.cs @@ -14,6 +14,9 @@ public sealed class ModelOperationRequestService( string scopeKey, string automationMode, string idempotencyKey, + DateTimeOffset scheduledFor, + string catchUpPolicy, + int maxCatchUp, string correlationId, CancellationToken cancellationToken) { @@ -21,6 +24,8 @@ public sealed class ModelOperationRequestService( ModelOperationExecutionBoundary.EnsureAllowed(definition); if (!definition.AutomationMode.ToContractValue().Equals(automationMode, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Schedule automation mode does not match the approved operation registry."); + if (maxCatchUp is < 0 or > 31) throw new ArgumentOutOfRangeException(nameof(maxCatchUp)); + if (string.IsNullOrWhiteSpace(catchUpPolicy)) throw new ArgumentException("Catch-up policy is required.", nameof(catchUpPolicy)); var now = clock.UtcNow; var context = await contextReader.ReadAsync(scopeKey, now, cancellationToken); @@ -35,6 +40,9 @@ public sealed class ModelOperationRequestService( scopeKey, definition.AutomationMode.ToContractValue(), idempotencyKey, + scheduledFor, + catchUpPolicy, + maxCatchUp, context, correlationId, now); diff --git a/src/KArtSell.Modules.ModelOperations/Application/ModelOperationsContracts.cs b/src/KArtSell.Modules.ModelOperations/Application/ModelOperationsContracts.cs index 0343e5a3..be8d7455 100644 --- a/src/KArtSell.Modules.ModelOperations/Application/ModelOperationsContracts.cs +++ b/src/KArtSell.Modules.ModelOperations/Application/ModelOperationsContracts.cs @@ -28,6 +28,9 @@ public sealed record ModelOperationRequest( string ScopeKey, string AutomationMode, string IdempotencyKey, + DateTimeOffset ScheduledFor, + string CatchUpPolicy, + int MaxCatchUp, ApprovedModelContext Context, string CorrelationId, DateTimeOffset RequestedAt); @@ -82,6 +85,9 @@ public interface IModelOperationRequestService string scopeKey, string automationMode, string idempotencyKey, + DateTimeOffset scheduledFor, + string catchUpPolicy, + int maxCatchUp, string correlationId, CancellationToken cancellationToken); } diff --git a/src/KArtSell.Modules.ModelOperations/Infrastructure/DapperModelOperationRequestRepository.cs b/src/KArtSell.Modules.ModelOperations/Infrastructure/DapperModelOperationRequestRepository.cs index cff6cddd..1c1659f9 100644 --- a/src/KArtSell.Modules.ModelOperations/Infrastructure/DapperModelOperationRequestRepository.cs +++ b/src/KArtSell.Modules.ModelOperations/Infrastructure/DapperModelOperationRequestRepository.cs @@ -15,11 +15,11 @@ public sealed class DapperModelOperationRequestRepository( insert into evaluation.model_operation_request (request_id, schedule_id, operation_code, scope_key, automation_mode, idempotency_key, dataset_id, data_hash, model_version, config_version, code_sha, contract_version, - lifecycle_state, correlation_id, status, requested_at) + lifecycle_state, correlation_id, status, requested_at, scheduled_for) values (@RequestId, @ScheduleId, @OperationCode, @ScopeKey, @AutomationMode, @IdempotencyKey, @DatasetId, @DataHash, @ModelVersion, @ConfigVersion, @CodeSha, @ContractVersion, - @LifecycleState, @CorrelationId, 'REQUESTED', @RequestedAt) + @LifecycleState, @CorrelationId, 'REQUESTED', @RequestedAt, @ScheduledFor) on conflict (idempotency_key) do nothing; """; @@ -47,6 +47,7 @@ public sealed class DapperModelOperationRequestRepository( request.ScopeKey, request.AutomationMode, request.IdempotencyKey, + request.ScheduledFor, request.Context.VersionSet.DatasetId, request.Context.VersionSet.DataHash, request.Context.VersionSet.ModelVersion, @@ -68,6 +69,9 @@ public sealed class DapperModelOperationRequestRepository( request.OperationCode, request.ScopeKey, request.AutomationMode, + request.ScheduledFor, + request.CatchUpPolicy, + request.MaxCatchUp, versionSet = request.Context.VersionSet, boundary = "NO_AUTO_MODEL_MUTATION" }); diff --git a/src/KArtSell.Modules.ModelOperations/Scheduling/ModelOperationsDispatcherJob.cs b/src/KArtSell.Modules.ModelOperations/Scheduling/ModelOperationsDispatcherJob.cs index c3c0acbc..b1ec0ded 100644 --- a/src/KArtSell.Modules.ModelOperations/Scheduling/ModelOperationsDispatcherJob.cs +++ b/src/KArtSell.Modules.ModelOperations/Scheduling/ModelOperationsDispatcherJob.cs @@ -49,7 +49,10 @@ public sealed class ModelOperationsDispatcherJob( item.OperationCode, item.ScopeKey, item.AutomationMode, - idempotencyKey)); + idempotencyKey, + occurrence, + item.CatchUpPolicy, + item.MaxCatchUp)); backgroundJobId = jobs.Create(job, new EnqueuedState(item.Queue)); } diff --git a/src/KArtSell.Modules.ModelOperations/Scheduling/ScheduledModelOperationJob.cs b/src/KArtSell.Modules.ModelOperations/Scheduling/ScheduledModelOperationJob.cs index a86a653e..5d8d57ee 100644 --- a/src/KArtSell.Modules.ModelOperations/Scheduling/ScheduledModelOperationJob.cs +++ b/src/KArtSell.Modules.ModelOperations/Scheduling/ScheduledModelOperationJob.cs @@ -26,7 +26,10 @@ public sealed class ScheduledModelOperationJob( string operationCode, string scopeKey, string automationMode, - string idempotencyKey) + string idempotencyKey, + DateTimeOffset scheduledFor, + string catchUpPolicy, + int maxCatchUp) { var correlationId = $"model-operation:{operationCode}:{Guid.NewGuid():N}"; var request = await service.RequestAsync( @@ -35,6 +38,9 @@ public sealed class ScheduledModelOperationJob( scopeKey, automationMode, idempotencyKey, + scheduledFor, + catchUpPolicy, + maxCatchUp, correlationId, CancellationToken.None); diff --git a/tests/KArtSell.ModelOperations.UnitTests/ModelOperationRequestServiceTests.cs b/tests/KArtSell.ModelOperations.UnitTests/ModelOperationRequestServiceTests.cs new file mode 100644 index 00000000..c62f796d --- /dev/null +++ b/tests/KArtSell.ModelOperations.UnitTests/ModelOperationRequestServiceTests.cs @@ -0,0 +1,56 @@ +using KArtSell.BuildingBlocks.Time; +using KArtSell.BuildingBlocks.Versioning; +using KArtSell.Modules.ModelOperations.Application; + +namespace KArtSell.ModelOperations.UnitTests; + +public sealed class ModelOperationRequestServiceTests +{ + [Fact] + public async Task Persists_the_scheduled_occurrence_and_catch_up_provenance() + { + var scheduledFor = new DateTimeOffset(2026, 8, 9, 1, 0, 0, TimeSpan.Zero); + var context = new ApprovedModelContext( + "GLOBAL", + new VersionSet("dataset", "data-hash", "model", "config", "code-sha", "contract"), + "APPROVED", + scheduledFor.AddDays(-1)); + var repository = new CapturingRequestRepository(); + var service = new ModelOperationRequestService( + new FixedContextReader(context), + repository, + new FixedClock(scheduledFor.AddHours(2))); + + var request = await service.RequestAsync( + Guid.NewGuid(), "J31", "GLOBAL", "EVALUATION_ONLY", "J31:GLOBAL:1:20260809010000Z", + scheduledFor, "ALL_WITH_LIMIT", 2, "correlation", CancellationToken.None); + + Assert.NotNull(request); + Assert.Same(request, repository.Request); + Assert.Equal(scheduledFor, request.ScheduledFor); + Assert.Equal("ALL_WITH_LIMIT", request.CatchUpPolicy); + Assert.Equal(2, request.MaxCatchUp); + } + + private sealed class FixedContextReader(ApprovedModelContext context) : IApprovedModelContextReader + { + public Task ReadAsync(string scopeKey, DateTimeOffset asOf, CancellationToken cancellationToken) + => Task.FromResult(context); + } + + private sealed class CapturingRequestRepository : IModelOperationRequestRepository + { + public ModelOperationRequest? Request { get; private set; } + + public Task AddAsync(ModelOperationRequest request, CancellationToken cancellationToken) + { + Request = request; + return Task.FromResult(true); + } + } + + private sealed class FixedClock(DateTimeOffset utcNow) : IClock + { + public DateTimeOffset UtcNow => utcNow; + } +}