Date arithmetic under lib, audited file by file
Change: every-term-on-the-engine-calendar, task 1.1. Row Q8.23 of the
competitor gap register, "is the calendar the deadline engine reads the
same calendar the organisation administers".
This file is the deliverable the proposal asked for: not a count, but a
verdict per file. The structural test
tests/Unit/Architecture/EveryTermOnTheCalendarTest.php reads the verdict
column of the table below, so a row here is a fact the build keeps true
rather than a note somebody wrote once.
How the set was built
Patterns, over lib/**/*.php, file names only:
grep -rlE "modify\('[+-]|new DateInterval|->add\(|->sub\(|strtotime\('[+-]" lib/ --include=*.php
Read at 16f00124a (development, 2026-09-14). The same five patterns
return the same 37 files at 9c478d810, the commit the proposal counted
at, so the set has not drifted since the row was rated.
Two of the patterns, ->add( and ->sub(, are not anchored to a date
type. They match IJobList::add() as readily as DateTimeImmutable::add(),
which is why two of the 37 rows below have the verdict neither with the
reason "not date arithmetic". The count is an upper bound on the problem,
never the problem itself.
The verdicts
statutory: a period the law names, ending on a date somebody can be held to. The beslistermijn, the bezwaartermijn, the ingebrekestelling grace, the hersteltermijn, the dwangsom window, the Woo decision term. Algemene termijnenwet art. 1 moves such a date off a Saturday, a Sunday or a recognised holiday, so it has to reach the working calendar.business: a date the organisation is judged on but the law does not name. It reads better on a working day and nobody is in breach when it does not.neither: a report window, a throughput bucket, a retention period in years, demo seed data, an intraday service norm, or a pattern false positive. A weekend cannot change the answer, so the calendar is not on its path.
Counts
at 9c478d810 (proposal) | at 16f00124a (this change, before) | after this change (task 3.1) | |
|---|---|---|---|
| files matching the patterns | 37 | 37 | 38 (39 since case-recycle-window) |
| of those, reaching a working calendar | 5 | 5 | 19 |
of those, verdict statutory | not measured | 16 | 16 |
statutory files reaching a calendar | not measured | 3 | 15 |
statutory files allowlisted with a named owner | not measured | n/a | 1 |
case-recycle-window added a thirty-ninth, lib/Service/Recycle/RetentionClocks.php: it
counts a lawful-purpose retention in months, which is the AVG side of the same question
ArchivalNominationDeriver answers for the Archiefwet, and it carries the same verdict.
lifecycle-acts-on-the-case added a fortieth, lib/Service/Lifecycle/SilenceCloseService.php.
It counts the administered period of silence a case type declares (REQ-LIFE-04) and the date
the applicant is told the case will close on. It is deliberately NOT statutory: nothing in the
Awb counts sixty days of nobody answering, the period is a gemeente's own setting, and rolling
it to a working day would move a date nobody is owed. Every other date in that change goes
through CaseDateNormaliser, which is why only this one line matches the patterns.
The set grew by one file, and by nothing else: lib/Service/TermijnTimerService.php
now calls the engine's SlaCalculator::add(), which the ->add( pattern
matches. It is the bridge, not a term, and its row says so.
The proposal counted five files referencing WorkingDayCalculator, and
those five are all inside the 37. A sixth file references it at
16f00124a, lib/Service/Milestone/StalledCaseDetector.php, which does no
date arithmetic of its own and so is not one of the 37. Two of the five,
lib/Service/Kcc/SlaCalculator.php (since retired) and the calculator itself, have the
verdict neither, which is why three rather than five statutory files
reached a calendar before this change.
The table
Lines are the matching lines at 16f00124a. Reaches names what the file
consults for the day a date lands on, after this change.
| file | lines | verdict | reaches | reason |
|---|---|---|---|---|
lib/BackgroundJob/AcknowledgementDispatchJob.php | 136 | neither | the retry counter for an acknowledgement that failed to send, attempt + 1 passed to IJobList::add(). It is one of the ->add( matches the pattern cannot anchor to a date type | |
lib/BackgroundJob/LinkCollectionToCaseJob.php | 90 | neither | queues the same dossier link again when the service account is missing, through IJobList::add(). One of the ->add( matches the pattern cannot anchor to a date type | |
lib/BackgroundJob/AdviceDeadlineJob.php | 89 | neither | a look-ahead window the daily scan uses to pick which advices to remind on, not a date anyone is held to | |
lib/Service/Setup/ExampleDataFragments.php | 177 | neither | example data dates relative to the moment the set is loaded (@today+2d), so a fresh install shows deadlines this week; fictional data, not a term anyone is held to | |
lib/BackgroundJob/DsoDeadlineJob.php | 196, 205 | statutory | WorkingDayCalculator | the Omgevingswet decision term; the day walk is already the calculator's |
lib/Flow/DossiqAskPersonNode.php | 648, 669 | neither | a flow task due date and a node timeout in minutes, both process plumbing | |
lib/Service/Milestone/MilestoneStallTimer.php | 154 | business | WorkingDayCalculator | the day after a milestone's scheduled deadline, when its stall timer breaches (termijnbewaking-op-engine-timers 3.1). The deadline itself comes from StalledCaseDetector and MilestoneSchedule, which count working days; the +1 day only moves the breach to the first day the case is late, the day BottleneckDetectionJob reported it. A milestone is a house norm, not a term the Awb names |
lib/Service/Pause/ChaseSchedule.php | 105 | business | WorkingDayCalculator | the interval between two reminders on a suspended term (pause-reason-with-chasing). The hersteltermijn ITSELF is statutory and DeadlinePauseService rolls it; a reminder inside that window is the gemeente's own schedule and nobody is in breach when one lands late. It still reaches the calendar, because a reason may declare countsWorkingDays and a reminder on a Sunday is a reminder nobody reads |
lib/Portal/PortalSubmissionLedger.php | 154 | neither | claims a portal submission key with IMemcache::add(); the ttl is cache plumbing, not a date anyone is held to. One of the ->add( matches | |
lib/Service/Intake/CollectionLinkQueue.php | 61 | neither | puts the dossier link on the job list with IJobList::add(). One of the ->add( matches | |
lib/Service/Session/SessionRules.php | 67, 84, 134 | neither | the generic form of the hearing planner's rules (C12): the inspection floor is a minimum notice counted backwards from the session, and rolling it forward would shorten it; the attendance grace window is hours after the session start, process plumbing | |
lib/Service/Queue/UrgencyScorer.php | 253 | business | WorkingDayRoll | the business days until a deadline, for the urgency tier of the work queue. It is a display count, not a term anyone is held to, and it asks the administered calendar first (one-term-engine, REQ-OTE-04); the day shift only aligns its bounds with the Monday to Friday fallback walk |
lib/Service/Queue/PersonalAgendaItemService.php | 177 | neither | the fortnight the personal queue looks ahead for items a person planned on their own calendar. It is a display window on a list, not a date anybody is held to, and moving it off a Saturday would hide a Saturday appointment from the person who booked it (one-personal-queue) | |
lib/Service/Queue/QueueFigures.php | 65 | neither | the coming week the team queue counts cases due within, for its "Deadline within 7 days" tile. A display window on a count, not a date anybody is held to; the days a case has waited are counted by DayCount | |
lib/Flow/DossiqRequestDecisionNode.php | 615 | neither | a node timeout in minutes | |
lib/Listener/AcknowledgementOnCreateListener.php | 113 | neither | queues the acknowledgement with attempt 1 through IJobList::add(), the same unanchored match. No date is computed here | |
lib/BackgroundJob/ReportGroupingJob.php | 93 | neither | requeues the report grouping of a case through IJobList::add() when no service account is set, the same unanchored match. No date is computed here | |
lib/Listener/ReportGroupingOnCreateListener.php | 88 | neither | queues the report grouping of a created case through IJobList::add(), the same unanchored match. No date is computed here | |
lib/Listener/CaseInheritedDeadlineListener.php | 181 | statutory | engine calendar | a deelzaak inherits the parent case type's term, so it inherits the term's end date |
lib/Service/Actions/ScheduleReminderHandler.php | 162, 167 | neither | when a reminder background job runs | |
lib/Service/Archival/ArchivalNominationDeriver.php | 259 | neither | a retention period counted in years, where a weekend cannot move the answer | |
lib/Service/Beschikking/BezwaarTermijnScheduler.php | 76, 77, 114 | statutory | engine calendar | Awb 6:7, the six week bezwaartermijn and its reminder; line 114 is the archive trigger the day after and follows the rolled end |
lib/Service/Beschikking/OpenRegisterArchivalAdapter.php | 121, 153 | neither | a retention period from the selectielijst, in years | |
lib/Service/Bezwaar/AdvisoryCommitteeService.php | 178 | business | the committee's own advice deadline, a house norm the Awb does not name | |
lib/Service/Bezwaar/HearingSchedulePlanner.php | 106, 153 | neither | the Awb 7:4 lid 2 inzage floor is a minimum notice counted backwards from the hearing; rolling an end date forward would shorten it | |
lib/Service/Bezwaar/HearingService.php | 570 | neither | a proposed hearing date two weeks out, which the planner then reschedules | |
lib/Service/CaseLifecycleService.php | 516, 521 | statutory | engine calendar | extends a case end date by the case type's period, which is the term a handler is judged on |
lib/Service/CaseTermsService.php | 249 | statutory | engine calendar | binds the planned end, the internal target, the phase term and a case type's fixed closing date; every one of them is a date somebody is held to, and endAfter() is the single line that turns a day count into one |
lib/Service/ComplaintAnalyticsService.php | 247 | neither | a six month reporting window | |
lib/Service/Termijn/TermDashboardFigures.php | 83 | neither | a seven day window on the deadline dashboard: which running terms end this week. It compares against each term's own endDateCurrent, which the engine already rolled; the quarter it reports on is a reporting window | |
lib/Service/ComplaintService.php | 408 | statutory | WorkingDayCalculator | Awb 9:11 klachttermijn in weeks; the service already consults the calculator for working days |
lib/Service/DeadlineExtensionService.php | 150 | statutory | rollTermEndFor | an extension named in days (Woo art. 4.4 lid 2) asks for the current end plus those days; the asked-for date then goes through the same Algemene termijnenwet roll as every extension (one-term-engine) |
lib/Service/DeadlinePauseService.php | 93, 172 | statutory | engine calendar | Awb 4:5 and 4:15: the credited suspension and the unused remainder both move endDateCurrent |
lib/Service/DemoCaseloadReport.php | 86 | neither | a three day horizon in a demo report | |
lib/Service/DemoCaseloadSeedDataService.php | 343 | neither | demo seed data | |
lib/Service/Doorlooptijd/CaseEnricher.php | 233 | business | engine calendar | the dashboard's derived expected end; it mirrors a statutory term, so it rolls with it or it contradicts it on screen |
lib/Service/Doorlooptijd/DeadlineComplianceCalculator.php | 168 | neither | month buckets for a compliance trend | |
lib/Service/DsoCaseService.php | 310 | statutory | WorkingDayCalculator | the Omgevingswet term; the day walk is already the calculator's |
lib/Service/Money/PayoutService.php | 133 | statutory | engine calendar | Awb 4:17: paymentDateLatest is the date the dwangsom payment is late after |
lib/Service/Kcc/CallbackRetrySchedule.php | 71 | neither | the wait before the next callback attempt, 15 minutes doubling to a day. It replaced Kcc/SlaCalculator.php (retired by termijnbewaking-op-engine-timers 4.1), whose channel SLAs nothing read; a retry wait is not a deadline and has no calendar | |
lib/Service/Money/AccrualNoticeService.php | 188 | statutory | engine calendar | Awb 4:17: the grace period whose end opens the dwangsom window |
lib/Service/ProcessMining/ThroughputTrendCalculator.php | 109 | neither | weekly buckets for a throughput trend | |
lib/Service/ProcessMiningService.php | 93, 96 | neither | a twelve month reporting window | |
lib/Service/Recycle/RetentionClocks.php | 144 | neither | a lawful-purpose retention counted in months (AVG art. 5.1e), where a weekend cannot move the answer; the same verdict ArchivalNominationDeriver carries for the Archiefwet side | |
lib/Service/Import/CaseRecordImport.php | 311-312 | neither | converts the case type's declared ISO extension period (P14D) into a number of days, measured from a fixed day, to hand to the term carry-over. No date anyone is held to is computed here: the extended end is the carried source term's own, and the term engine rolls it (case-record-import, REQ-CRI-002) | |
lib/Service/Lifecycle/SilenceCloseService.php | 131 | neither | an ADMINISTERED period of silence a case type declares, after which the product closes the case, and the date it announces. Not a term: nothing in the Awb counts sixty days of nobody answering, the number is a gemeente's own setting, and a weekend cannot move an answer to "has anything happened". Same verdict as RetentionClocks for the same reason | |
lib/Service/QuickActionService.php | 158 | statutory | engine calendar | Awb 9:11: the six week klacht decision term, written on intake from the KCC |
lib/Service/Status/StatusDwellService.php | 135 | business | WorkingDayCalculator | how long a case has sat in one status, a service level and never a term. The BREACH is the engine's: StatusDwellTimer arms it in the engine's own businessDays unit over the calendar the organisation administers. The count held on the case is the local calculator's, because the engine exposes projection and no count between two dates, so the two can differ by a day on a custom calendar. Closing that needs a count operation in openregister working-calendar-admin |
lib/Service/Remedy/RemedyLodging.php | 195 | statutory | engine calendar | Awb 6:7 and 6:8, the term a remedy is lodged within, counted from the day the decision was made known; the end rolls to the next working day (Algemene termijnenwet art. 1) before a lodging is judged timely |
lib/Service/Stuf/StufOutboundTransport.php | 298 | neither | not date arithmetic: IJobList::add() matched the ->add( pattern | |
lib/Service/Subsidie/BeschikkingService.php | 82 | statutory | engine calendar | the bezwaartermijn of a subsidy beschikking |
lib/Service/Subsidie/BewijsstukService.php | 136 | neither | a record retention period in years | |
lib/Service/Subsidie/SubsidieService.php | 151 | statutory | engine calendar | the Awb decision term of a subsidy application |
lib/Service/Subsidie/TerugvorderingService.php | 88, 101 | statutory | engine calendar | the bezwaartermijn and the payment term of a clawback decision |
lib/Service/Subsidie/TussenrapportageService.php | 99 | statutory | engine calendar | the assessment term of an interim report, the date the applicant is answered by |
lib/Service/TermijnNotificationService.php | 97 | neither | not date arithmetic: IJobList::add() matched the ->add( pattern | |
lib/Service/Term/FirstResponseOutcome.php | 243 | business | WorkingDayCalculator | the overrun on a first-response term (term-configuration-beyond-the-case-type). The +1 day is not a term end: it is where the count of days we were late STARTS, because the day we promised by is not a day we were late. The counting itself is the calculator's, so this file keeps no holiday list of its own. It moves to the engine calendar when one answers "how many working days between these two dates"; WorkingDayRoll rolls a single date and cannot answer that yet |
lib/Service/TermijnTimerService.php | 481 | neither | itself | not date arithmetic: the engine's SlaCalculator::add() matched the ->add( pattern. This file IS the bridge; it asks the engine for the roll and computes no date of its own |
lib/Service/TermijnService.php | 109 | statutory | engine calendar | endDateCalculated. Taken by terms-on-the-engine-calendar: createTermijnInstance() routes through TermijnTimerService::rollTermEndFor(), the one call every other term site makes, so the stored date and the armed timer come from one computation |
lib/Service/WOODeadlineService.php | 101, 172 | statutory | engine calendar | Woo art. 4.4: the decision term and its statutory extension |
lib/Service/Custody/CaseTakeoverRequest.php | 299 | business | the answer period a handler has before a takeover request escalates. An internal service clock, not a term the applicant is owed, so it counts calendar days on purpose and reaches no calendar: the escalation is about how long the request has sat, and a weekend it sat through is time it sat | |
lib/Service/Term/DependentTermOffer.php | 228 | statutory | allowlisted | the end date a dependent-term offer PROPOSES, endDateCurrent + N days, which a handler then approves. It is a statutory end date and it does not reach the calendar, so an offer can propose a Saturday. Owned by dependent-term-follows-predecessor; the one-line fix is to route it through TermijnTimerService::rollTermEndFor(), in that change rather than in a build fix |
lib/Service/Termijn/WorkingDayRoll.php | 196 | neither | itself | not date arithmetic of its own: it ASKS the engine calendar (SlaCalculator::add() of zero business days, which matched the ->add( pattern) for the first working moment at or after an instant. This file is the calendar accessor the intake start uses; it holds no list, decides no policy, and since #2951 it does not read rollToWorkingDay either, because TermijnTimerService::rollEnabled() is the one place that decides |
lib/Service/Termijn/TermEndRoll.php | 221 | neither | itself | not date arithmetic of its own: it ASKS the engine calendar (SlaCalculator::add() of zero business days, which matched the ->add( pattern) to roll a statutory end date off a weekend or a holiday. This is the Algemene termijnenwet roll, split out of TermijnTimerService so the admin screen can ask the roll whether a calendar answers without asking the timer service. The computation did not change when it moved, and the file it moved out of already carried this verdict |
lib/Service/Termijn/TermDefinitions.php | 247, 260 | statutory | engine calendar | endDateCalculated, which is the row lib/Service/TermijnService.php carried at line 109 until the definitions got their own class. The computation did not change when it moved: TermijnService::createTermijnInstance() still routes the answer through TermijnTimerService::rollTermEndFor(), the one call every other term site makes, so the stored date and the armed timer come from one computation. Working-day definitions reach WorkingDayRoll from here, and a calendar that does not answer is logged at warning as a term that is SHORTER than it is owed |
lib/Service/WorkingDayCalculator.php | 170, 204 | neither | itself | the calendar. It is the fallback the three sites use when the engine is absent, and the only holiday list dossiq is allowed to hold |
lib/Woo/WooThroughputReport.php | 164 | neither | the throughput report adds one verdict to a per-reviewer, per-day tally through its own add() method. It counts work done on a day; no term is computed and nobody is held to a date | |
lib/Service/Term/ApplicantTermNote.php | 256 | neither | not date arithmetic: it parses the case type's extensionPeriod (ISO 8601) into a DateInterval only to say the period in words ("twee weken") in the sentence the applicant reads. No date is computed; the day the decision is due is copied from the case deadline the term engine keeps |
Rows added after the reading
lib/BackgroundJob/AcknowledgementDispatchJob.php and
lib/Listener/AcknowledgementOnCreateListener.php did not exist at
16f00124a and so are not in the 37 counted above. The change
ontvangstbevestiging added them, and both are ->add( matches on
IJobList, not on a date. They carry the verdict neither for the reason
this file already names: two of the five patterns cannot be anchored to a
date type. lib/BackgroundJob/ReportGroupingJob.php and
lib/Listener/ReportGroupingOnCreateListener.php arrived later the same way,
with ai-features-on-the-case-consume-hermiq, and carry the same verdict for
the same reason. The counts below describe the reading, not the table.
lib/Service/Termijn/TermDefinitions.php and
lib/Service/Termijn/TermEndRoll.php are the third and the fourth. Neither existed at
16f00124a: the roll moved out of lib/Service/TermijnTimerService.php and the
definitions out of lib/Service/TermijnService.php, both when those classes went
over their complexity ceiling. Each row carries the verdict the file it left
already carried, because each call is the same call.
What the table is not
It is not a defect list. Twenty of the 37 have the verdict neither, and
for each of them the reason is the point: saying once, in writing, that a
throughput bucket does not need a working day roll is what stops the next
reader running the same grep and reaching the same uncertainty.
Two rows deserve their own sentence.
lib/Service/Bezwaar/HearingSchedulePlanner.phplooks statutory and is not, in the sense this audit means. Awb 7:4 lid 2 gives a minimum of seven days between the inzage and the hearing. Algemene termijnenwet art. 1 lengthens a term by moving its end forward; applied to a floor counted backwards it would shorten the citizen's inzage, which is the opposite of what the Awt is for. The floor stays raw, deliberately.lib/Service/TermijnService.phpis the one statutory file this change does not fix. It is the fileterms-on-the-engine-calendarnames as its motivating defect, and taking it here would edit another lane's open change. It is allowlisted with that change as its owner.