OpenSPP Registry: Reject Future Birthdates

Platform: OpenSPP (OpenSPP2) · Modules: spp_registry, spp_change_request_v2, spp_mis_demo_v2 · Type: Backend / Data integrity · Status: Merged into 19.0 on 16 September 2026 · Issue: #362 · PR: #397

Context

OpenSPP stores a beneficiary’s date of birth on res.partner.birthdate, defined in spp_registry/models/individual.py. A non-stored computed field, age, derives years of age from that date using relativedelta(now, dob).

In the web form, a small guard called _birthdate_onchange already reacts when someone types a future date: it clears the field and shows a friendly warning.

Problem

That onchange guard only runs in the form UI. Every other write path skips it entirely, so a birthdate in the future is accepted through:

Once a future birthdate is stored, the computed age goes negative and renders as a string such as "-3" in views, exports, and anywhere else that reads the field — a confusing and invalid value for downstream program logic.

Background

This is pre-existing behavior that was explicitly flagged as out of scope in PR #357. That PR removed a dead @api.constrains("age") guard which looked like it covered this case but never actually ran — constrains hooks do not fire on non-stored computed fields, so the removal was behavior-preserving.

No @api.constrains on birthdate existed anywhere in the repository. The only related check is registration_date >= birthdate in spp_registry/models/registrant.py, which does not reject a future date of birth on its own.

My Approach

The fix is a stored-field constraint that fires on every create/write path, giving a reliable server-side backstop while the friendly form onchange stays in place for a gentle in-UI experience. This is the version that shipped:

@api.constrains("birthdate")
def _check_birthdate_not_future(self):
    for record in self:
        if record.birthdate and record.birthdate > fields.Date.context_today(record):
            raise ValidationError(
                _(
                    "Date of birth cannot be in the future: %(name)s has %(date)s.",
                    name=record.display_name,
                    date=record.birthdate,
                )
            )

Because birthdate is a stored, writeable field, the constraint runs on create, write, import, and API paths alike — and, unlike the removed age guard, produces no registry-load warning. I kept it consistent with the existing _check_registration_date constraint already living in registrant.py.

Two details in that snippet came out of review and are worth naming. The comparison is against fields.Date.context_today(record), the user’s own today, rather than the server’s UTC date: a registrar east of UTC is already on tomorrow’s date for part of each day, and a birth recorded that morning is not a future date for them. And the message names the record and the offending value, so a single mistyped row in a five-thousand-row import can be found — a bare string would abort the batch with nothing to search on.

Guarding the change-request path

Closing the registry hole moved the failure somewhere worse. A proposed date of birth is also stored on the change-request models — Add Member, Edit Individual, the Create Group member lines and the Create Group member wizard — and none of them had a guard of their own. A future date survived submission and review, and was only refused when a strategy wrote it to res.partner on the final approval, rolling back the whole approval with an error the approver could not trace back to a field.

A shared spp.cr.birthdate.mixin now applies the same rule at data entry, using the same comparison as the registry constraint so the two cannot disagree and let a value through one to be refused by the other at apply time.

Tests

Following the project’s test-first workflow, I added a TestBirthdateNotFutureConstraint class in spp_registry/tests/test_constraints.py. It exercises the write paths that skip the form onchange — create, write, and load() (the ORM path CSV/Excel import uses under the hood) — plus the boundary cases that must still pass: a birthdate of today, and an ordinary past date. A final test asserts that an approximate date of birth still can’t be set in the future.

The create and import tests needed more care than they first appeared to. registration_date defaults to today, and _check_registration_date refuses any registration date earlier than the birthdate — which is true of every date once the birthdate is in the future. Both tests therefore passed on the base branch without the new constraint existing at all, because a neighbouring constraint was raising instead. They now pass registration_date explicitly empty, which short-circuits that check, and assert on the message text, so removing the constraint fails them. Their dates derive from context_today so they cannot go flaky on a runner whose local date differs from UTC.

Two further suites cover the widened scope: test_birthdate_mixin.py for the shared change-request guard, and test_edit_individual_prefill_skips_future_birthdate for the prefill regression described below — it plants the bad date with raw SQL, since the ORM now refuses it.

Considerations

Fix Process

I followed the standard OpenSPP contribution flow so the change landed cleanly:

Review

The pull request went through three rounds, and the review is where most of the work happened.

Round one raised seven points. Two of my tests passed on the base branch without the constraint existing, for the registration_date reason above. The changelog fragment named a version below what already shipped, and the manifest was not bumped at all. The constraint compared against the server’s UTC date. The error message identified neither the record nor the value. The new translatable string was missing from the catalogues. And the largest of them: the change-request models store their own proposed birthdate, so the failure would land at final approval rather than at data entry — the point that led to the shared mixin.

Round two requested changes on a regression the new guard introduced. The change-request detail prefills a registrant’s existing birthdate with a plain write, so an Edit Individual request could no longer be created for a registrant already holding a future date — closing the very path field staff would use to correct one. I fixed that in the prefill mapping rather than at the write, so the onchange path that consumes the same mapping is covered too, and moved the rule onto the mixin so what prefill declines to offer and what the constraint refuses cannot drift apart.

The demo generator in the same round needed more than the one-line change suggested. fields.Date.context_today(self) cannot go where the old call sat: STORY_CHANGE_REQUESTS is a class attribute, so there is no self in scope, and the fields.Date.today() literal it carried was evaluated once at import and then frozen — stale as well as UTC-bound. The definition now declares birthdate_days_back and _build_detail_changes resolves it against fields.Date.context_today at build time, which closes both faults at once.

Round three approved. Both reviewers ran the branch rather than reading the diff — the approving maintainer neutralised my prefill fix to confirm the regression test actually fails without it, and checked the paths the test does not cover.

Outcome

PR #397 merged into 19.0 on 16 September 2026, squashed as 1a3c5919 — 28 files, +540/−41, all 35 checks green. It ships as spp_registry 19.0.2.2.4, spp_change_request_v2 19.0.3.1.16 and spp_mis_demo_v2 19.0.2.1.5, and closes issue #362.

Two non-blocking findings are filed as #520 — the sibling registration_date default and the form onchange still compare against the server date, so the east-of-UTC case the constraint now handles is still refused on create — and #521, where manual: true is not a valid pre-commit hook key, so the README generators run in the default stage and every changelog-fragment PR fails pre-commit until the generated files are committed.

One thread stays open by agreement. The 19.0.3.1.16 changelog line says a valid date has to be entered once prefill stops offering a refused one, which is stronger than the code enforces: birthdate is mapped for apply like any other field and empty values are applied deliberately, so a request approved without one clears the registrant’s impossible date rather than carrying it forward. The maintainer raised it as non-blocking, and the wording gets corrected in the next change to that module rather than re-spinning the generated READMEs for prose alone.

Reflection

I like this as a first contribution because it’s small in code but careful in reasoning: the interesting part isn’t the constraint itself, it’s understanding why the old guard never fired, where every write path enters the model, and how to protect existing data without breaking unrelated saves.

The review taught me more than the fix did. Three rounds took this from six lines in one module to a guard that holds at data entry across three, with tests that fail when the constraint is removed rather than passing for a neighbouring reason. Closing one hole opened another twice over — at approval time, then at prefill — and both were only visible to someone running the branch.

For a social protection platform, a clean date of birth is not a cosmetic detail — it feeds eligibility and program logic, so getting the validation right genuinely matters for the people the system serves.