TL;DR — My first OpenSPP contribution fixes a small bug with a big shadow: a beneficiary's date of birth could be set to a date in the future, which made the computed age go negative and render as things like -3 across the registry. The web form already blocked it — but only the form. Every other way data enters the system walked straight past that guard. This post is about what I found when I followed each write path, why the one check that looked like it covered this was dead code, and the seven rules I now hold myself to about where validation actually belongs. The system is OpenSPP, an open-source social protection platform, which raises the stakes: on a platform like this, a wrong age isn't cosmetic — it feeds eligibility.
The myth I had to give up first
For a long time I believed that if the form validated an input, the input was validated. You type a bad value, the UI catches it, the user fixes it — done. That belief is comfortable because it's usually true from where you're sitting, which is in front of the form.
It stops being true the moment you remember that a form is one door into the building, not the building itself.
OpenSPP stores a beneficiary's date of birth on birthdate, and derives a non-stored computed age from it. 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. If the form were the only door, this bug wouldn't exist. But a registry is written to by imports, scripts, and APIs far more than by people typing into a form — and none of them ring the doorbell.
So I stopped asking "does the form catch this?" and started asking the only question that matters for a system of record: does every path that can write this field catch it?
What I found when I followed every write path
I traced each way a birthdate can be set. The findings were consistent, and each one peels back the same assumption.
The onchange guard only runs in the form UI. This is not an OpenSPP quirk — it's how Odoo works. @api.onchange methods fire on form-view interaction and are never triggered by direct write calls. The friendly reset I was relying on is, by design, a UI convenience, not a data guarantee.
Every non-UI path skips it entirely. A future birthdate is accepted through direct ORM calls (create() / write()), through CSV and Excel import, and through the API surfaces (XML-RPC, API v2, DCI endpoints). Each of those is a door with no guard on it. In a social registry, those are the busy doors.
The check that looked like it covered this was dead code. There had been an @api.constrains("age") guard that appeared to validate exactly this. It never ran. age is a non-stored computed field, and a constraint on a field like that doesn't fire the way a constraint on a real, stored column does. It was removed in an earlier PR (#357) as a behavior-preserving cleanup — removing something that already did nothing. The lesson landed hard: a check that silently validates nothing is worse than no check, because it buys you false confidence.
Once stored, the bad value spreads. A future birthdate makes the computed age negative, and that -3 then renders in views, exports, and anywhere downstream logic reads the field. The invalid value doesn't stay where it entered; it leaks into every surface that trusts the registry.
And this is a social protection platform. A clean date of birth isn't a display detail here — age feeds eligibility and program logic. A negative age is, concretely, a person being mis-seen by the exact system meant to protect them.
Mapping what I found to rules I now hold myself to
Findings are only worth the tracing if they change what I do next time. Here's the translation, each rule tied to what I saw and to the fix I shipped.
Rule 1 — Validate where every write path converges, not at the door the user happens to use
From: the onchange guard runs only in the form; every other path skips it.
The form is one entrypoint; the model is where they all meet. So the guarantee has to live on the model. A stored-field constraint runs on create, write, import, and API alike, because all of them ultimately go through the ORM:
@api.constrains("birthdate")
def _check_birthdate_not_future(self):
for record in self:
if record.birthdate and record.birthdate > fields.Date.today():
raise ValidationError(_("Date of birth cannot be in the future."))
Rule 2 — Constrain the input you store, not the value you derive
From: the dead @api.constrains("age") on a non-stored computed field.
The old guard tried to validate age, a derived value that isn't a real column — so it never fired. The fix constrains birthdate, the stored field the user actually writes. Validate the thing that gets persisted, and know which of your framework's hooks genuinely run versus which only look like they do.
Rule 3 — Keep the gentle guard and the hard backstop
From: the onchange's silent-reset UX is friendlier than a raised error.
The right move isn't to replace the form guard with the constraint — it's to keep both. The onchange stays as a soft, in-UI first line of defense that quietly corrects a typo before anyone hits save; the constraint sits behind it as the backstop that no import or API call can bypass. Defense in depth, not defense in replacement.
Rule 4 — When you refuse, speak the user's domain
From: the raw failure is a negative number and schema noise; the person needs a sentence.
A refusal has to say what's wrong in the user's world, not the parser's. The message is Date of birth cannot be in the future. — a plain, domain-level statement — not a schema violation about a stored column or a stray -3 surfacing three screens away. The registry's users think in beneficiaries and dates, so the error talks in beneficiaries and dates.
Rule 5 — A write-time constraint doesn't heal history
From: a constraint only validates on write.
Records that already hold a future birthdate stay invalid until they're next touched — and could then block otherwise-unrelated saves. So a constraint isn't the whole job for a deployed database; it wants a paired data-quality check or a migration note to find and fix the values that slipped in before the guard existed. Stopping new bad data and cleaning old bad data are two tasks, not one.
Rule 6 — Test every path you claim to cover, before the fix
From: the bug existed precisely because only one path was ever exercised.
I'm following the standard OpenSPP flow: write the failing test first, then the fix, and make the test cover create, write, and import — not just the form. A fix I only proved on the path that already worked would be repeating the original mistake in a nicer font.
Rule 7 — On a social platform, correctness is dignity, not polish
From: age feeds eligibility and program logic.
This is the spine the other six hang on. The reason to get a two-line constraint exactly right isn't tidiness — it's that a clean date of birth decides whether a real person is represented accurately to the program deciding what they receive. "Small bug" and "small stakes" are not the same thing.
The synthesis: validate where every path converges
Put it together and the shape is simple. A form guard answers "did this user, in this UI, enter something sane?" A model constraint answers "can this invalid value exist in the database at all, no matter who or what wrote it?" Those are different questions, and for a system of record only the second one keeps you safe.
The bug was never really about birthdates. It was about mistaking the door I could see for the whole building. The interesting part of the fix isn't the two-line constraint — 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.
You don't validate in the form. You validate at the layer every path has to pass through — and you say what's wrong in words the person on the other end can actually use.
Takeaways
- The form is a door, not the building. UI validation guards one entrypoint; a system of record is written to by imports, scripts, and APIs that never touch it.
- Put the guarantee on the model. A stored-field constraint runs on create, write, import, and API alike, because they all converge on the ORM.
- Constrain what you store, not what you derive. A check on a non-stored computed field can be dead code that silently validates nothing — the most expensive kind of check, because it feels safe.
- Keep the gentle guard and the hard backstop. Defense in depth: a friendly onchange in front, an unbypassable constraint behind.
- Speak the user's domain when you refuse. "Date of birth cannot be in the future," not a schema error or a stray negative number.
- A write-time constraint doesn't heal history — pair it with a data-quality check or migration note for records that predate the guard.
- On a social protection platform, correct data is a person's dignity. That's why a two-line fix is worth this much care.
References
- OpenSPP — Issue #362, spp_registry accepts future birthdates via ORM / import / API. https://github.com/OpenSPP/OpenSPP2/issues/362
- OpenSPP — PR #357, the behavior-preserving removal of the dead
@api.constrains("age")guard. https://github.com/OpenSPP/OpenSPP2/pull/357 - Odoo 17 — ORM API reference (
@api.constrains,@api.onchange, computed fields). https://www.odoo.com/documentation/17.0/developer/reference/backend/orm.html - OpenSPP — the platform this contribution is for. https://openspp.org/en/
Top comments (0)