## What & why
S-17: a BIG inscription is valid for a fixed term; before it lapses the zorgprofessional must herregistreren. This adds a **daily herregistratie reminder sweep**.
- **Domain:** `Approve(ingeschrevenOp)` now stamps the inscription moment; `HerregistratieVoor` derives the deadline (inscription + 5-year validity); `HerregistratieReminderDue(asOf)` is the single rule (inside the 90-day window, inscribed, not yet reminded); `MarkHerregistratieReminderVerstuurd()` is idempotent.
- **Store:** `FindDueForHerregistratieReminderAsync(asOf)` — the sweep's candidate set, filtered on the aggregate's own rule (no duplicated policy).
- **Application:** `HerregistratieReminderSweep` — pure over the store + an injected `TimeProvider`; flags + persists each due inscription, returns the reminded ids.
- **Infra/API:** `HerregistratieReminderJob` (Quartz `IJob`) fires the sweep on a daily cron (03:00, overridable via `Quartz__Cron`) and logs the count. `GET /registrations/{id}` surfaces `herregistratieVoor` + `herregistratieReminderVerstuurd`.
**Decisions (both raised with you before coding):** use Quartz.NET as the PRD names it — a genuine cron concern, distinct from the queue-draining pumps, which stay as-is (**ADR-0022**, proposal #120); and the reminder's observable effect is a flag on the aggregate + a log line (no outbound notification infra in v1). No coupling rule (§8) is touched — Quartz is internal to the Domain Service.
Closes #18
Closes #120
## Definition of Done
- [x] Linked Gitea issue (above).
- [x] Failing test committed before the implementation (red→green per layer: domain rule, store query, sweep).
- [x] Implementation makes the test pass; refactor commit for the 90-day knob.
- [x] Conventional Commits referencing the issue (`refs #18`).
- [ ] CI green — awaiting Gitea Actions.
- [ ] `docker compose up` reaches green health checks within 3 minutes — API boots locally with Quartz initialised; verified in CI compose smoke.
- [x] Docs updated — ADR-0022, demo-script, BACKLOG.
- [x] ADR added — `docs/architecture/adr-0022-quartz-scheduler.md`.
- [x] Demo note in `docs/demo-script.md`.
## Notes for reviewers
- **Ripple:** `Approve()` gained the inscription moment, so the two approving handlers (`ApproveRegistration`, `BeoordeelRegistratie`) now take an injected `TimeProvider`; existing tests pass a fixed clock. All three `IRegistrationStore` implementers (prod, unit fake, acceptance) got the new query.
- **Calibration knobs:** validity (5y) and reminder lead time (90d) are domain constants marked with `ponytail:` comments; promotion path to beheer config (S-15) noted in the ADR.
- **Mutation:** the Quartz job shell is excluded from Stryker, mirroring the pumps; all rule/sweep/query logic is covered.
- Local: 152 domain unit tests green; API boots with the Quartz scheduler and `/health` green.
Reviewed-on: #121
3.5 KiB
ADR-0022: Quartz.NET for time-triggered fleet sweeps
- Status: Accepted
- Date: 2026-07-23
- Deciders: Respellion engineering
- Slice: S-17 (#18) · Proposal issue: #120
Context
A BIG inscription is valid for a fixed term; before it lapses the zorgprofessional must herregistreren. S-17 adds a herregistratie reminder sweep: once a day, scan the register for inscriptions whose deadline is within the reminder window and remind each one.
The Domain Service already runs periodic background work — OpenZaakJobPump,
BeoordelingEscalatiePump, RegistratieVerlopenPump. Those are continuous job
pollers: they drain Flowable's external-task/job queues at-least-once, picking up
work as soon as it is parked, on a short poll interval. The reminder sweep is a
different shape of work: time-triggered, once a day, over our own store — there
is no queue to drain and no "as soon as possible" requirement.
The PRD already names the scheduler component: "Scheduler (Quartz.NET): fleet-wide sweeps (expiry, reminders)" (§39, §94). Adding Quartz.NET is nonetheless a new dependency, so this decision is recorded before the code lands (CLAUDE.md §14).
Decision
Use Quartz.NET for time-triggered fleet sweeps, starting with the herregistratie
reminder sweep. Leave the existing pumps as BackgroundService job pollers.
HerregistratieReminderJob(a QuartzIJob) is fired by a cron trigger — daily at 03:00 by default, overridable withQuartz__Cron. It is a thin shell: it resolves the pureHerregistratieReminderSweep(application layer) and logs how many reminders went out.- The sweep's rule lives in the domain:
Registration.HerregistratieReminderDue(asOf), which the store query and the sweep both build on. The sweep marks each reminded inscription (HerregistratieReminderVerstuurd), so a re-fire reminds no one twice (§8.6).
Two options were rejected:
- A
BackgroundServicewith a 24hTask.Delay. No new dependency, but it drifts to process-start time, has no cron/misfire semantics, and contradicts the PRD's named component. A daily "run at 03:00" is exactly what cron scheduling is for. - Migrating the three pumps onto Quartz too, for one mechanism. Rejected: the pumps are not schedulers. Forcing a "run at time T" tool onto "drain this queue continuously" work is churn and a boundary change for negative benefit. The teachable distinction is worth keeping: pumps drain queues; Quartz fires sweeps.
Consequences
Positive
- Cron scheduling with restart-stable timing and misfire handling, for free.
- The reminder rule is one domain method, reused by the store query and the sweep; the scheduler owns none of the policy.
- The reference app now demonstrates the intended Scheduler component.
Negative / costs
- One new dependency (
Quartz,Quartz.Extensions.Hosting) in the Domain Service. - Two periodic-work mechanisms coexist (pumps + Quartz). Deliberate — they model two genuinely different concerns, documented here.
Follow-up
- The validity term (5 years) and reminder lead time (16 weeks) are domain calibration knobs; promote them to beheer config (S-15) if a demo needs them per-catalogus.
- The Quartz job stores its schedule in RAM (
RAMJobStore); a persistent/clustered store is a later concern if the Domain Service is scaled out.
Coupling rules touched (CLAUDE.md §8)
None. Quartz is internal to the Domain Service and drives an application use case over the store port. No ZGW or Flowable coupling is added; the sweep talks to no peer module.