Enrichment Guard Rails
Four runtime checks plus a tightened date parser that stop stale or malformed SPEAR data from polluting predicted title dates
Overview
Predicted title dates are derived from SPEAR milestone timestamps. SPEAR data is not always clean - it can have placeholder strings, regressed timestamps, or entries from parcels that have already been titled. Before 3.8 these edge cases silently propagated into incorrect predicted dates.
Release 3.8 adds four runtime guard rails in the dossier-api enrichment path plus a stricter parseDateValue() on the platform side.
The Four Guard Rails
1. Titled-parcel skip
If title_status === 'titled', computeInsightEnrichmentDates() returns the title_date from parcel registration and short-circuits the SOC / street-addressing arithmetic entirely. Stale SPEAR milestones on already-titled parcels can no longer produce meaningless "predicted" dates.
2. Past-predicted-date drop
If SOC + titleOffsetDays or streetMilestone + streetOffsetMonths computes to a date in the past, the predicted fields are set to null. The SOC date itself is retained (still a real datapoint) but the projected-into-past prediction is dropped. Stops 2022-era SOCs from producing "predicted 2022-09-15" strings that look like they're from yesterday.
3. Newest completed SOC milestone wins
SPEAR can return multiple "Statement of Compliance" rows for the same plan (re-submissions, amendments, etc.). Previously the first row encountered won. Now the sort prefers:
milestoneReached === true(completed milestones beat in-flight ones).- Newest parseable completion date.
Only after those preferences does iteration order matter. Prevents an abandoned early SOC from overriding a successful later one.
4. Monotonic SOC on persist
On persist, the platform refuses to replace a later SOC date with an earlier one. Protects against SPEAR cache regressions (rare, but happen if a milestone is deleted and re-added).
Tightened parseDateValue()
The enrichment service's date parser previously fell back to new DateTimeImmutable($string) if no explicit format matched. That silently accepted garbage: "not issued", "TBC 2025", and even malformed strings returned a date (usually today, from PHP's relative parsing).
Now explicit formats are checked first (Y-m-d, d/m/Y, ISO 8601), and anything outside the valid year range 1900-2100 is rejected with null instead of a silently-produced fake date. A scan of dev DB turned up 148 title_date rows with pre-1900 values that were never supposed to be there; they now fail loudly at enrichment time instead of silently re-parsing.
What a failed guard looks like
- Guard rail 1 returns
{ title_date: <actual>, predicted_title_date: null, predicted_title_date_formatted: null }. - Guard rail 2 returns
{ soc_date: <actual>, predicted_title_date: null, predicted_title_date_formatted: null }- SOC kept, projected-past dropped. - Guard rails 3 + 4 affect which milestone or SOC "wins" but return normal shape.
parseDateValue()returning null on malformed input lets the caller treat the field as "unknown" instead of the old behaviour of silently accepting today's date.
Related
- Daily SPEAR Refresh Cron - the cron that exercises these guards every 24h
- Estate-Level Prediction Offsets - the offsets feeding the arithmetic