Skip to content

EverTask 4.0: durable occurrences, misfire policies, time zones, schedule management, exclusions - #55

Merged
GiampaoloGabba merged 61 commits into
masterfrom
feature/issue23-durable-occurrences
Aug 31, 2026
Merged

GiampaoloGabba merged 61 commits into
masterfrom
feature/issue23-durable-occurrences

Conversation

@GiampaoloGabba

Copy link
Copy Markdown
Owner

EverTask 4.0: durable occurrences, misfire policies, time zones, runtime schedule management, exclusions

The 4.0 epic: a recurring schedule is no longer frozen at dispatch, and a downtime no longer silently loses work.

What ships

  • Durable occurrences (opt-in): every due slot becomes its own one-shot row with its own status, retries and audit trail; the schedule row holds a definition plus a cursor.
  • Misfire policies: Skip (default, unchanged), FireOnce, CatchUp with mandatory caps (CatchUpOptions(maxAge, maxOccurrences), overflow Halt/SkipOldest) — a backlog is replayed observably, never invented and never unbounded.
  • Execution context: handlers read Context.ScheduledAtUtc/ScheduledAtLocal/Misfire (kind, missed range, exact-or-lower-bound count, lateness).
  • Time zones with real DST semantics (InTimeZone, IANA persisted): 09:00 means 09:00 there, all year; gap and repeated-hour rules are pinned against Cronos as oracle.
  • Runtime schedule management (ITaskScheduleManager): reschedule/reevaluate/resume/cancel/requeue with compare-and-swap discipline; RebaseFromCursor for phase-preserving cadence changes.
  • Occurrence providers (INextOccurrenceProvider): the grid can come from your own calendar, with declared determinism, typed transient failures and bounded walks.
  • Recurring exclusions (Recurring: composable exclusions over any grid - fixed skip days/dates/ranges + named blackout/holiday calendars #36): .Except(days/dates/windows), .ExceptWeekends(), and named host-level calendars (AddScheduleCalendar + .ExceptCalendar("it-holidays")) — an excluded slot never exists (no run, no misfire count, no row, no event); calendar edits apply forward-only from an immutable per-host snapshot.
  • MySQL/MariaDB storage provider, monitoring API + dashboard coverage of the durable side, opt-in management endpoints, ET0008–ET0010 analyzers, [LoggerMessage] logging, System.Text.Json serialization.

Verification

Compatibility

  • Storage schema migrates automatically (EF migrations included, all four providers); rows written by 3.x stay readable, serialized shapes are byte-stable for pre-4.0 definitions.
  • 3.11-compiled consumers keep binding (new members are DIMs/optional; historical IL signatures untouched).
  • Rollback to 3.x after running 4.0 durable schedules is a documented boundary, not supported silently.

Closes #23
Closes #36

https://claude.ai/code/session_01Gh9X6ALEP5DSNSQYDdpBru

…r fixture (#23)

- X6/P6 rev. v1.1: next release is 4.0.0 - the major is forced by the
  Abstractions namespace flattening already on master (62d2010, breaking,
  no type forwarders), not by #23; the #23 changes stay additive
- consumer compatibility fixture redefined: compiled against the
  issue23-baseline packages (local nupkg feed) instead of the published
  3.11 nupkg, which can no longer load post-62d2010
- golden-byte JSON fixtures stay anchored to the 3.11 wire format
  (persisted-data compatibility is independent of the recompile)
- orchestrator lenses/titles updated accordingly; development runs on the
  feature/issue23-durable-occurrences branch, PR to master

Claude-Session: https://claude.ai/code/session_0133AHLGT4SoFa4JWtkZFuQj
…inistico, compatibilita, (#24)

- Introduce the IScheduleEvaluator/ScheduleEvaluator seam and route every next-run
  computation (dispatcher, worker, schedulers, recurring builders) through it
- Make scheduling deterministic (P9): TimeProvider flows through schedulers,
  rate limiting and the recurring builders, with FakeTimeProvider-driven tests
- Preserve JSON/record compatibility and add the OccurrenceMode scaffold plus a
  binary-compatibility baseline consumer fixture and golden-JSON tests
- Add the RecoveredTaskFactory and split recovery into execution vs finalization
  (X3), with the two-pass recovery page and finalization-failure handling
- Ship one AddDurableOccurrences migration per provider (SqlServer, Postgres,
  MySql, Sqlite) with the durable-occurrence columns, constraints and procedures
- Implement atomic storage operations (materialize occurrence, schedule CAS,
  cancel/requeue/halt) at each provider's optimization tier, plus in-memory parity
…ntext) (#25)

- add ITaskExecutionContext, MisfireInfo/MisfireKind and ITaskExecutionContextAccessor to EverTask.Abstractions, with a no-op SetExecutionContext DIM on IEverTaskHandler<T> (non-breaking) and a protected Context on EverTaskHandler<T>
- build one context per delivery in WorkerExecutor.DoWorkCore, publish it to the singleton ambient AsyncLocal accessor and inject it through delegates compiled once per handler type (the same cache now also carries SetLogCapture, previously per-execution reflection)
- track Attempt across the retry loop and RunNumber durably (stamped by the dispatcher as CurrentRunCount + 1, advanced by QueueNextOccourrence) with no extra storage round-trip
- resolve the nominal slot through TaskHandlerExecutor.NominalSlotOfDelivery so a rate-limit deferral moves the delivery but never the slot the handler and dashboard see
- add SetMisfireThreshold(TimeSpan) (default 5s, observation only) driving Misfire.Kind = Late
- update docs, cheatsheet, reference and the integrate-evertask skill; add execution-context and eager-scope integration tests
- Add InTimeZone(TimeZoneInfo|string) as default interface members on the
  builder interfaces, implemented by all seven internal builders; the zone is
  persisted as a normalized IANA id in the schedule JSON
  (RecurringTask.TimeZoneId, [JsonIgnore(WhenWritingNull)], no new column).
- Introduce ScheduleSemantics to classify each schedule as Calendar or Elapsed,
  so that only calendar-anchored schedules are governed by a time zone.
- Add WallClock to map a nominal wall slot to an instant with the T6/T7 rules:
  exponential bracket plus tick bisection for a DST gap, first pass for a
  repeated hour, and Consumed so collapsed slots fire exactly once.
- Delegate cron to Cronos with the zone and wire Cronos in as the test oracle.
- Add SetDefaultScheduleTimeZone, stamping calendar schedules at dispatch, and
  expose TimeZoneId / ScheduledAtLocal on the task execution context.
- Keep the legacy path byte-identical: GoverningZone short-circuits on a null
  id before any new code runs; docs, cheatsheet, reference and the
  integrate-evertask skill updated, plus the new time-zones test suite.
- OccurrenceMode.Durable: la riga schedule diventa definizione + cursore; OccurrenceMaterializer crea una riga figlio per slot dovuto (RunAsync idempotente, lock per-padre, budget globale, kick non-throwing, seconda ondata della recovery)
- Misfire policies tipizzate: OnMisfire(Skip/FireOnce/CatchUp), CatchUpOptions (MaxAge, MaxOccurrences con Halt/SkipOldest, MaxPendingOccurrences), WithDurableOccurrences, BackfillFrom
- DueSlotEnumerator: conteggi bounded, SetMisfireThreshold applicato alla materializzazione, Halt solo se il budget residuo supera il cap, perdite tipizzate per causa (finestra 1802 / overflow 1819 / skip 1820)
- Storage: TryAdvanceScheduleCursor, TryHaltSchedule, retention delle occorrenze, parità SqlServer/Postgres/MySql/Sqlite/Memory; recovery R8 con seconda scansione keyset
- Test: DueSlotEnumeratorTests, DurableOccurrencesIntegrationTests, CatchUpRecoveryIntegrationTests (SQLite), multi-host SqlServer, validazioni negative, scheduler senza SupportsScheduleInspection
- Docs, cheatsheet, reference, skill integrate-evertask, CLAUDE.md di modulo; LoadHarness con celle LRA/LDM; decisioni §3.5 (ratifiche di fase 4)

Claude-Session: https://claude.ai/code/session_01DR6CCxkRRsZmPZhoTz79Sr
- add ITaskScheduleManager to EverTask.Abstractions (Reschedule, ReevaluateSchedule, ResumeSchedule, RequeueFailedOccurrence, CancelSchedule) with RescheduleMode and ScheduleUpdateResult
- implement TaskScheduleManager and its log class (EventId 2200-2299), extracting the dispatcher per-taskKey critical section into TaskKeyLockRegistry so dispatch and reschedule of the same key share it
- add ScheduleVersionRegistry (S4) plus SchedulePeriodKind, ScheduleRebase and RecurringTask.PeriodKind for the M18 nominal-period rebase
- WorkerExecutor drops stale inline deliveries and advances the schedule with a compare-and-swap that re-aims on VersionMismatch
- no storage members or migrations needed: phase 1 already shipped UpdateSchedule, RequeueTerminal and the CAS overloads at each provider optimization tier, so the legacy advance path stays byte-identical
- update docs, cheatsheet, reference, the integrate-evertask skill, the ASP.NET sample and four CLAUDE.md files; 31 new tests (21 integration, 10 rebase unit)
- add the public INextOccurrenceProvider contract, NextOccurrenceRequest and OccurrenceProviderException, plus a key-based registry resolved per call in its own DI scope
- expose UseOccurrenceProvider on the fluent recurring builder and route every schedule primitive through a provider branch behind IScheduleEvaluator, so misfire policies, durable occurrences, time zones, skip-forward and ITaskScheduleManager work over a provider unchanged
- implement the V4 failure model: an unknown key is a configuration error at dispatch and a terminal poison at recovery, while a provider exception is transient (nothing written, backoff re-park, warning event, recovery poison counter untouched)
- add SetOccurrenceProviderRetry to configure the transient backoff, with its own retry registry and provider logging
- document the feature end to end (new occurrence-providers page, cheatsheet, configuration reference, integrate-evertask skill) and ship a BusinessDaysProvider sample
- cover the phase with 37 new tests; full suite green on net8/net9/net10 with 0 warnings
…les, release 4.0.0 (#30)

- monitoring api: durable-occurrence facts on TaskListDto/TaskDetailDto as init properties, new GET /tasks/{id}/occurrences backed by ITaskStorage.GetOccurrences, occurrence/catch-up filters, TaskCountsDto.Occurrences and OverviewDto.CatchUpBacklog, with TaskScheduleFacts as the single JSON-column reader
- dashboard ui: mirrored TS types, occurrences tab on durable schedules, catch-up/fire-once/late badges, halt alert and the catch-up backlog KPI on the overview
- docs and skills sweep: monitoring events/api/dashboard pages, architecture, recurring-tasks, index and README refreshed; every "3.12.0" marker rewritten to 4.0.0; .agents mirror and integrate-evertask skill resynced
- samples: NightlyReconciliationTask (02:00 Europe/Rome, 92-day catch-up) plus schedule/backfill/resume reconciliation endpoints
- release: CHANGELOG [Unreleased] cut as [4.0.0] - 2026-08-26, with the missing phase 4/5/6 entries written and stale cross-references repointed
- tests: OccurrenceEndpointTests on a real host with the real dispatcher and materializer, plus API-reference sample and wire-contract tests
…ntry points (#37)

- QueuedTask.NormalizeTimestampsToUtc rewrites every timestamp to the same instant at offset zero
- applied in Persist/UpdateTask of the EF base (inherited by all four relational providers) and MemoryTaskStorage
- SQLite stores DateTimeOffset as TEXT with the offset inside: a row written at +02:00 lost every cursor CAS forever
- contract documented on ITaskStorage for custom storages; CLAUDE.md notes updated
- pinned by two cross-provider contract tests (red without the fix on MaterializeOccurrence/TryHaltSchedule)

Closes #37

Claude-Session: https://claude.ai/code/session_01DR6CCxkRRsZmPZhoTz79Sr
)

- both poison primitives are best-effort: the recovery summary inferred terminalization from the call returning
- TryPoisonAsync re-reads the row and measures it against IsRecoverableForExecution + IsRecurringSeriesToFinalize
- a poison write that throws is contained (no longer aborts the whole recovery wave); shutdown OCE rethrown
- honest accounting: unconfirmed poison counts as transient, not permanent; per-site poisoned logs gated on confirmation
- EventIds 1131/1132; pinned by RecoveryPoisonOutcomeTests (real MemoryTaskStorage behind FaultInjectingTaskStorage, 3/4 red without the fix)

Closes #38

Claude-Session: https://claude.ai/code/session_01DR6CCxkRRsZmPZhoTz79Sr
…d idle queues (#39)

- the reader no longer awaits each page's wave: up to MaxRecoveryPagesInFlight (4) waves run at once
- a slot is freed by whichever wave finishes first, so one wedged page costs one slot, not the pipeline
- gate sits before the page read (memory stays bounded by a constant, R8); M7 barrier drains every wave before the second pass
- a failing wave still ends the recovery; the waves in flight are settled, not abandoned
- same pipeline on the durable second scan; pinned by RecoveryPagePipelineTests (red at cap 1 = pre-fix behaviour)

Closes #39

Claude-Session: https://claude.ai/code/session_01DR6CCxkRRsZmPZhoTz79Sr
- a handler that never builds burned no counter: the row stayed non-terminal for the process lifetime and froze the series under MaxPendingOccurrences=1
- reuses the recovery's L18 counter (IncrementRecoveryFailure) with the same default ceiling of 5 consecutive failures; a successful rebuild clears it
- at the ceiling the row goes through the same confirmed FailUnusableOccurrenceAsync, with its own sentence (EventId 1825 + monitoring event); 1817 now says attempt N of M
- RequeueTerminal resets the counter so a requeued row gets its attempts back (all providers via the EF base)
- pinned by two integration tests (red without the fix at DurableOccurrencesIntegrationTests.cs:750/:801)

Closes #41

Claude-Session: https://claude.ai/code/session_01DR6CCxkRRsZmPZhoTz79Sr
…nd shared probes (#40)

- real root cause: the host's startup recovery captures its cutoff after StartAsync returns, so a row dispatched in that window is legitimately re-dispatched (one extra handler resolution) - order-dependent, not the counters alone
- the resolution-counting test now waits for the recovery's terminal log line (new shared StartupRecoveryWatch helper)
- the two absolute-count tests get a dedicated probe pair each (the issue's ask); assertions unchanged

Closes #40

Claude-Session: https://claude.ai/code/session_01DR6CCxkRRsZmPZhoTz79Sr
… it (#43)

- three tests read the row's counter right after the handler's own counter, but the worker writes CurrentRunCount a storage round-trip later
- they now wait on WaitForRecurringRunsAsync (audit + counter); an always-true guard became an explicit ShouldBe(2), so the interval assertion always runs
- a counter never written still fails the wait: nothing weakened

Closes #43

Claude-Session: https://claude.ai/code/session_01DR6CCxkRRsZmPZhoTz79Sr
… the engine (#45)

- write pressure moves into T-SQL loops over the real usp_SetTaskStatus (client thread-pool starvation under a full-solution run kept the cycle from forming)
- stop stays between bursts; early stop on a failed read; engine deadlock counter (DMV) read before/after so the failure message tells no-collision apart from a storage that stopped re-running reads
- still fails when nothing collides: readFailures/reread assertions untouched (no dynamic skip in xUnit 2.x without a new package)

Closes #45

Claude-Session: https://claude.ai/code/session_01DR6CCxkRRsZmPZhoTz79Sr
… model (#42)

- second role (read vs operate) on the existing JWT: only the new ManagementUsername/Password credential grants operate; dashboard credential and magic link stay read
- gate lives in the auth middleware on the /api/management prefix: 404 when EnableManagementEndpoints=false (default, backward compatible), 401/403 otherwise; ManagementAuthorization hook replaces the role check
- auth disabled does not open the write surface (403 unless the host hook says otherwise); CSRF ruled out and documented (Bearer-only, no cookies)
- POST tasks/{id}/requeue|resume|cancel over ITaskScheduleManager (optional: 501 standalone); outcome-to-HTTP mapping in one place; fixed-time credential compares
- 14-test authorization matrix incl. real cancel against storage; OpenAPI hides the routes while disabled; docs triad + api reference updated

Closes #42

Claude-Session: https://claude.ai/code/session_01DR6CCxkRRsZmPZhoTz79Sr
- two new ITaskStorage DIMs (GetStatusAuditsPage/GetRunsAuditsPage -> AuditPage<T> with TotalCount); existing signatures untouched, defaults compose the unpaged read for custom storages
- indexed overrides: EF base (Count + Skip/Take on the QueuedTaskId index, identity order = insertion order, take=0 answers only the total), Memory, SqlServer deadlock re-read
- endpoints take skip/take like /execution-logs and answer {audits, totalCount, skip, take}; task detail carries the first page + both totals
- UI: one AuditTrailTab for both trails with Previous/Next, reads the endpoints; SignalR invalidation by query-key prefix
- pinned end-to-end: 7 endpoint tests, cross-provider storage tests (4 providers + memory + real SQLite), 6 Vitest UI tests; pnpm build/lint/test green

Closes #44

Claude-Session: https://claude.ai/code/session_01DR6CCxkRRsZmPZhoTz79Sr
…nly chain (#34)

- mirrors the runtime classification (IsCalendarAnchored), not the fluent names: Day/Week/Month intervals are calendar on their own, OnHours() classifies as elapsed
- proof-based receiver walk: unrecognized call, split chain, foreign type or a second chain in scope aborts - zero false positives by construction
- warning only: the runtime InvalidOperationException stays the contract
- 34 analyzer tests with three-mutation red/green evidence; docs (time-zones.md, CHANGELOG, skill) and analyzer range updated to ET0010

Closes #34

Claude-Session: https://claude.ai/code/session_01DR6CCxkRRsZmPZhoTz79Sr
…orward the audit pages in the fault wrapper

- the WorkerService registration became a factory lambda, so removal by ImplementationType silently stopped matching: the worker ran in every worker-off test and its recovery re-parked the seeded completed recurring row mid-test (timing-dependent TotalCount 30 vs 31)
- the filter now matches the lambda return type too and THROWS when nothing is removed, so it cannot regress silently
- FaultInjectingTaskStorage forwards GetStatusAuditsPage/GetRunsAuditsPage (its own contract test caught the gap)

Claude-Session: https://claude.ai/code/session_01DR6CCxkRRsZmPZhoTz79Sr
…re-auth hook evaluation (#42)

- the authoritative gate is now an IAsyncAuthorizationFilter attached by route convention to everything under {prefix}/api/management: it runs inside routing (after UsePathBase) and after the host's UseAuthentication, so an anonymous request through a path base can no longer reach the actions and the ManagementAuthorization hook finally sees the authenticated principal
- the middleware keeps only the 404 shield for the disabled surface; the filter re-validates the bearer on its own (header only, never the query-string fallback)
- startup validation refuses a management password equal to the read password or the magic-link token (and half-configured pairs)
- 4 new tests (PathBase bypass dead with the row proven untouched, hook on the host principal, credential distinctness); the original 14-test matrix unchanged and green

Hardens the fix for #42 after the consolidated adversarial review of the fix series.

Claude-Session: https://claude.ai/code/session_01DR6CCxkRRsZmPZhoTz79Sr
…se (#46)

- one MonitoringAccessPolicy (surface classification, IP whitelist with CIDR, JWT) judged on the path routing resolved, after the path base
- enforcement inside routing: MonitoringAccessFilter on every monitoring controller (order -1000, before the management gate) and an endpoint guard wrapping the non-MVC endpoints (SignalR hub, dashboard files, OpenAPI, companion packages) via an empty-prefix route group
- the middleware shrinks to an outer shield; host endpoints are never wrapped (pinned by test); behavior identical without a path base (existing matrix green unchanged)
- 7 new tests: reads/UI-IP/hub all answered 200 anonymously under a path base before the fix, now 401/403 with the row/behavior proven; anonymous auth paths and authenticated hub handshake still work
- known functional (non-security) limit documented: the CORS branch still matches pre-path-base

Closes #46

Claude-Session: https://claude.ai/code/session_01DR6CCxkRRsZmPZhoTz79Sr
- the client address is Connection.RemoteIpAddress, nothing else: a spoofed header carrying a whitelisted address no longer walks through (the dashboard files had no other layer)
- reverse-proxy hosts use the framework's answer: UseForwardedHeaders with KnownProxies/KnownNetworks rewrites the address before routing - documented in reference/cheatsheet/skill with a BREAKING note in the 4.0.0 CHANGELOG (plus a sober Fixed line for #46)
- all access decisions now live inside routing (the middleware keeps only the 404 shield): the host's UsePathBase and UseForwardedHeaders have both acted before the policy judges
- 5 new tests: spoof refused on API and dashboard (200 pre-fix), the documented proxy path works with a known proxy, a stranger forwarded by a trusted proxy still refused, plain connection address admitted

Closes #47

Claude-Session: https://claude.ai/code/session_01DR6CCxkRRsZmPZhoTz79Sr
- A stranded occurrence is permanently Failed after ~5 minutes of a transient
  handler-activation failure, because the rebuild ceiling is spent per operational
  retry instead of per restart
- RebaseFromCursor silently skips the pending occurrence and re-phases a monthly
  schedule that names several days
- BackfillFrom on a month cadence with OnDays starts the cursor past a listed day
  it was pointed at
- RecurringInfo renders RunUntil in the HOST machine's local time and then labels
  it with the schedule's time zone
- Occurrence retention cascade-deletes StatusAudit/RunsAudit rows inside their own
  configured window
- CHANGELOG 4.0.0 claims "The API stays read-only" while the same release ships
  three write endpoints, and has no entry for #42 or #44
- EverTask.Monitor.Api package README (the nuget.org page) still asserts every
  endpoint is read-only and omits the whole management surface
- docs/storage/custom-storage.md never states the two new obligations a 4.0 custom
  ITaskStorage has (UTC normalization on Persist/UpdateTask, clearing the failure
  counter in RequeueTerminal)
- Plugin marketplace entry is frozen at 1.1.1 across the whole 4.0.0 rewrite and
  still advertises analyzers ET0001-ET0008
- 3,270 lines of new #23 tests live in test projects CI never executes
- CI excludes the SQL Server and PostgreSQL runs of the four-provider storage
  contract suite
- Every call of the five new atomic storage operations in the contract suite passes
  AuditLevel.Full, so the non-audited branch is untested on a
- Redispatch revival removes the blacklist entry that was covering in-flight
  cancelled occurrences, which then execute.
- A cancelled-then-redispatched durable schedule keeps its Halted marker and
  never materializes again.
- A policy that prunes only one audit trail turns OccurrenceRetentionDays into
  a permanent no-op.
- Reviving a cancelled schedule drops the blacklist entry that is the only
  guard for its in-flight occurrences.
- Reviving a cancelled INLINE schedule uncovers its own in-flight delivery, which runs the cancelled series and evicts the new registration
- The revival's WaitingQueue write is best-effort: a swallowed failure strands the redispatched series terminally Cancelled behind a success answer
- The revival's un-cancel is a best-effort SetStatus whose failure is swallowed, and the log still reports the series restarted
- A re-dispatch under the task key rewrites a Cancelled schedule row to Completed, erasing the cancellation and stranding the cancel's blacklist entry
…le event shapes (#23)

Post-final-review fix series, part 4 of 4 (documentation lens, plus the
maintainer decisions record).

- The storage guides no longer recommend "scale-out / multi-instance"
  deployments the runtime does not support: SQL Server/PostgreSQL rows now say
  high write concurrency on ONE active host per store, standby allowed, and
  point at the scalability page (integrate-evertask skill, postgres/mysql/
  sqlite/overview pages, configuration reference).
- TrySetTerminalOutcome joined every place that enumerates the versioned
  ITaskStorage contract: custom-storage guide, integrate-evertask skill,
  CHANGELOG 4.0.0 operation list.
- monitoring-events.md documents both stable shapes of the occurrence-unusable
  message and the failed-revival Error event, so consumers can alert on all of
  them.
- SetMisfireThreshold XML and release note no longer claim delivery-time-only
  effect: the durable planner consumes it too.
- RecurringTask.ToString appends RunUntil/MaxRuns on the cron branch like the
  fluent and provider branches (regression test included in part 1's suite).
- The integrate-evertask "every knob" example gains the three scheduling knobs
  it omitted.
- CHANGELOG: BREAKING entries for the ratified removals of
  ITaskStorage.GetCurrentRunCount and TimeOnly.ToUniversalTime.
- decisions.md §3.6 records the post-final-review maintainer ratifications
  (removals, CI exclusion, perf findings deferred to issues #48-#54 with the
  composite index pulled in-branch).
The ConsumerCompatibility.Baseline fixture, the local nupkg feed, NuGet.config
and the baseline-driven tests were development inputs: the additivity proof ran
in the workflow gates and the final review, and CI only needs the shipped code
green. The permanent guards stay: the golden-byte JSON fixtures and the
LegacyMinimalTaskStorage compile pin. Recreate a fixture against the published
4.0.0 packages if a 4.x minor ever needs the binary proof again; the
issue23-baseline git tag remains for history.
review/recurring-occurrences-final-report.md replaces the synthesis the aborted
final review never ran: per-phase outcomes, review and fix-series history, test
totals on net8/9/10, perf versus baseline, known limits, verdict GO for 4.0.0.
The two limits the review left undocumented are now written down: 4.0 is an
upgrade boundary for durable schedules (no 3.x rollback while children exist;
the Down migration deletes them by design), and a custom IScheduler that keeps
the TrySchedule default cannot refuse a stale in-flight registration (CHANGELOG,
durable-occurrences guide, IScheduler XML).
… storage docs pruned

README leads with what 4.0 actually changes — durable occurrences with
observable misfire handling, execution context, runtime schedule management —
without dropping anything it already covered: the analyzer table (stale at
ET0007) becomes a paragraph with a link, the bottom NuGet list goes (the badges
already carry it), the roadmap section shrinks to a pointer. ROADMAP.md is
rewritten from its 3.2-era state: planned items in plain words, shipped
milestones condensed, no effort estimates and no references to internal files.

The docs touched by #23 get a humanizer pass: promotional fillers and
AI-pattern phrasing removed, technical content, tables and event texts
untouched. The three new recurring pages needed nothing. The user-facing
storage pages also drop their schema internals (column tables, index and
procedure names, CAS mechanics) — users need behavior and configuration, and
migrations apply themselves; custom-storage.md keeps the ITaskStorage contract,
restated engine-neutral.
…tion sets

The certification and review cycles grew these files into design diaries: the
core file alone reached 775 lines of narrated proofs, keyed by review-plan
sigles (S4, M7, P5, ...) that reference documents which will leave the repo.
Every operative invariant survives as a 1-3 line bullet stating the constraint
and its why in plain words; the narratives die here — their content already
lives in the decisions file and the commit history.

The core file lands at 397 lines, above the usual 40-100 budget on purpose:
it is the declared home of the queue/recovery no-loss invariants and what
remains is all normative. The other six oversized files land inside the
budget. Stale references were corrected against the current code
(TrySetTerminalOutcome added, GetCurrentRunCount and the unpaged audit
members gone).
…xtensions with tests

The monitoring-logs guide showed the raw storage-contract member
(GetExecutionLogsAsync with skip/take) as the way to read persisted logs;
the paged GetLogsAsync extensions are the intended consumer surface. The
guide now uses them and notes where the raw member belongs (custom storage).

TaskStorageExtensions had no tests: added coverage over the real
MemoryTaskStorage for ordering, paging math, argument validation and the
page-size cap.
…ner separator

The AspnetCore sample now registers two recurring demos that exercise the
4.0 surface end to end: a durable every-20-seconds schedule with MaxRuns,
and a nightly Europe/Rome schedule backfilled three days into the past with
a CatchUp policy — the pair the dashboard verification ran against.

The task-detail halt banner concatenated the API's Reason fragment (no
trailing period, by contract) straight into the next sentence, rendering
"…cap At least N slots…". The banner now inserts the separator itself.
Comments now say WHY in 1-3 lines or not at all: development-plan sigles,
review narratives, proof-of-correctness essays and test-evidence notes are
gone; what survives is the invariant markers, the XML docs on the public
surface and the GitHub issue references. MediatR attribution comments are
untouched. No code change — verified by a clean Release build and the full
Docker-free test suite.

Also fixes three ambiguous crefs the sweep exposed.
The description now names what 4.0 actually ships (durable occurrences,
misfire policies, time zones, rate limiting, dashboard) and the tags cover
the searches that should find it.
The per-phase adversarial reviews and the followups registry served the
development run; every followup entry is now a GitHub issue, so the
tracked record of #23 stays at the three durable documents. The untracked
files remain on disk, ignored like the other local review artifacts.
…n, serialization (#36)

First slice of the fixed-exclusions feature: ScheduleExclusions/ExclusionRange
beside the definition, the Except/ExceptWeekends fluent surface as default
interface members with per-builder redeclarations, canonical normalization in
Validate (sorted/deduped days and dates, UTC-normalized merged half-open
ranges, 1000-entry cap, all-seven-days and provider-coexistence refusals),
the relaxed Elapsed-zone rule for calendar exclusions with the default zone
stamped at ingress, and the bounded ToString clause.

The grid does not evaluate exclusions yet - IsUniformGrid already answers
false so nothing downstream lies in the interim; the evaluator lands in the
next slice. Spec: review/recurring-exclusions-spec.md.
The filtered door: GetNextOccurrence discards excluded candidates through an
anchored loop that always advances from a real unfiltered occurrence, crossing
each excluded region in one step where phase allows it - the uniform base grid
jumps to the region exit through the self-verified tick arithmetic, cron asks
Cronos from the exit (phase-free), calendar grids walk slot by slot. Day and
date exclusions read the persisted zone's clock through the WallClock mapping
(gap-removed midnights land on the gap exit); ranges compare absolute. Only
the returned occurrence reports its own DST collapse count.

A search that discards 200,000 candidates throws
ExclusionSearchBudgetExceededException instead of answering null: an ended
series is mathematics, an exhausted budget is not, and the callers route the
difference (next slice). Skip-forward keeps its O(1) contract on fine grids by
jumping the base grid and filter-fixing the candidate; IsUniformGrid and the
constant-time miss count honestly answer false with exclusions present, and
the no-exclusion path stays byte-identical.

Spec: review/recurring-exclusions-spec.md.
The last slice: a stored cursor that a definition change left on an excluded
slot is normalized through the evaluator's NormalizeCursorAsync (inclusive,
never anchored on the possibly-off-grid stored value) before the inline
recovery decision and the durable plan; the durable planner persists the
normalized cursor through the cursor-only CAS even when the plan materializes
nothing, inline recovery re-derives deterministically and never writes, and a
pending first-run override is exempt by its provenance - SpecificRunTime only
on cursor equality, RunNow/InitialDelay inherently selected at run zero.

The search-budget exception now has one route per path: dispatch and every
schedule-manager write surface it with nothing persisted, startup recovery
sends it through the bounded-attempt counter to poison, the materializer
re-parks, and the live advance records the completed run first - cursor
retained, ScheduleRunAlreadyRecorded so a storage-less retry cannot count the
run twice, and a lost versioned advance re-parks from the row that took over.
RebaseFromCursor refuses exclusions on either side, naming RecalculateFromNow.

ET0010 no longer reports a chain carrying Except/ExceptWeekends on either
side of InTimeZone. User docs, integrate-evertask skill, CHANGELOG and README
updated in the same slice per the anti-stale rule.

Spec: review/recurring-exclusions-spec.md. Closes #36 (tranche 1; named
calendars stay open as their own issue).
…#36)

The exclusion-budget retry path is now a true twin of the provider retry.
The storage-backed retry resumes the interrupted ADVANCE instead of
re-entering the recovery decision, so a run already recorded on the row is
never delivered twice; restart safety comes from a runtime marker (retained
cursor + post-run count) persisted in the existing RuntimeInfo JSON in the
same commit as the run - self-invalidating the moment the row really
advances, no schema change. Built-in stores write it atomically through the
new RecordRecurringRunForExclusionRetry storage member (a default composed
of existing members keeps custom stores compiling, with their historical
two-write crash window documented). The deferral and the failed park now
publish Warning/Error monitoring events beside their log lines, and the
refusal reports itself with its own line.

The overloaded null is gone from the exclusion paths: FirstOccurrenceOnOrAfter
exhausting its probe budget with exclusions present throws the typed budget
exception instead of reading as "the grid has no occurrence" (a live series
was silently finalized - reproduced RED first on a filtered weekly grid);
a uniform-base jump that fails self-verification falls back to the anchored
walk; the filter loop carries the same defensive no-progress bail as the
file's other walks. The no-exclusions paths stay byte-identical.

Coverage the review demanded: versioned and unversioned budget-catch
variants (run counted exactly once, cursor retained, no double execution),
inline-recovery cursor normalization (fires Monday, row keeps Saturday),
cron region-jump cost (a 365-day window that a per-slot walk cannot afford),
ET0010 forward-walk conversion unwrap (latent one-token defect) - all RED
first where a defect existed.

Review: review/recurring-exclusions-adversarial-review.md (1 P0, 2 P1,
4 P2 confirmed; 2 under-verified hardened defensively; 5 refuted).
…r the

retry marker, skill bump (#36)

The README gets a real showcase of the feature beside the other "closer look"
sections, not just the Key Features bullet. custom-storage.md documents the
new RecordRecurringRunForExclusionRetry member in the atomic-operations table
(default two-commit composition vs atomic override). The CHANGELOG covers
what the review fix round added: the restart-surviving retry marker, the two
monitoring events and the storage member. The recurring guide names the
backoff's monitoring events. integrate-evertask skill bumped to 2.1.0 for the
exclusion sections it gained. The Recurring CLAUDE.md gotcha follows the
fix-round reality (marker, resume semantics, FirstOccurrenceOnOrAfter typed
cap failure).
…alidation (#36)

First slice of tranche 2: AddScheduleCalendar collects named exclusion sets
during the AddEverTask callback and the deep-frozen registry snapshot is
created and registered as an instance the moment the callback returns -
singleton factories are lazy, and a freeze at resolution time would let later
mutations of the configuration object change this host's calendar meanings.
ExceptCalendar joins the fluent surface with the usual DIM-plus-redeclaration
pattern; ScheduleExclusions gains the persisted Calendars names (trimmed,
sorted, deduplicated, at most 16), and a calendars-only exclusion set is no
longer normalized away.

The tranche-1 normalization moved into ScheduleExclusionNormalizer, shared
verbatim by validation, registration and the registry's single resolution
primitive: names are capped before any lookup, the flattened union is
re-normalized after the merge, so the 1000-entry cap and the all-seven-days
refusal now judge the union a schedule actually evaluates. Validation
travels as ScheduleValidationContext (providers + calendars) at dispatch and
schedule-manager ingress. Serialization pins three contracts: no-exclusion
rows stay byte-identical, tranche-1-shaped JSON still reads, and a rewrite
adopts the canonical shape with the empty Calendars member - legal only
because the exclusion shape ships for the first time in 4.0.0.

The grid does not evaluate calendars yet: the door still filters inline
exclusions only. The evaluator's resolved clone and the row-rebuild
validation land in the next slice. Spec:
review/recurring-exclusion-calendars-spec.md.
The evaluator resolves the named calendars once per top-level call: the
registry's primitive flattens inline plus calendar sets into a canonical
union installed on a transient evaluation clone (Calendars emptied), and the
existing pure math runs unchanged on it. A definition whose names were never
resolved is refused by the filtered door - an evaluator built without the
registry (the hand-wired fallback) refuses rather than silently running
through every holiday - and the no-exclusions fast path stays byte-identical.

The ScheduleValidationContext now travels every path that rebuilds a row
into something that may park or execute: startup recovery, the schedule
retry decision, the materializer and its repark builder, and
ReparkFromRowAsync - a row naming a calendar this host does not register is
poisoned with the calendar in the reason instead of executing once. The
registry-free surfaces are pinned by tests: ToString reports names verbatim,
TaskScheduleFacts and GetMinimumInterval never resolve, ScheduleRebase keeps
refusing exclusions.

Forward-only edit semantics pinned end to end: narrowing exposes only slots
at or after the standing cursor, widening revokes nothing already
materialized, a halt survives an edit, a live exclusion-retry marker resumes
against the new union, and an edit that makes the resolved union invalid
poisons at recovery. ET0010 stays silent on ExceptCalendar in both chain
orders. User docs and the integration skill cover AddScheduleCalendar and
ExceptCalendar. Spec: review/recurring-exclusion-calendars-spec.md.
…ired on FromRow (#36)

The calendars review confirmed one finding - a pinning gap, not a runtime
defect: the ScheduleValidationContext reaching RecoveredTaskFactory.FromRow
was an optional parameter, so a caller silently dropping it compiled and the
suite stayed green while a row naming an unknown calendar would execute once
through repark, schedule retry or the materializer walk. Three tests now hold
those guards (each proven RED by removing the context from its production
call site, then restored), and the contract is structural: production
FromRow REQUIRES the context; the deliberately registry-free callers go
through the explicit FromRowWithoutRegistries seam.

Review: review/recurring-calendars-adversarial-review.md (1 P2 confirmed,
6 refuted, 0 under-verified).

This branch had an error being deployed

1 failed deployment
release — ee102167 Deployed Aug 31, 2026 by GiampaoloGabba via build-and-test #140
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant