Context
A framework like Odoo exposes one stored model through many write paths: the form UI, ORM create() and write(), CSV and Excel import, and remote calls over XML-RPC, the API, and DCI. Each of these paths reaches the same database rows, but they do not share the same guards.
A validation hook attached to the form (@api.onchange) runs only while a user edits in the UI. Every other path (import, RPC, direct ORM) sails past it. This means a rule that feels enforced, because it works when you click through the form, can be silently absent everywhere else.
The invalid value is accepted, stored, and only noticed later when something downstream reads it: a computed age renders as a negative string, a plan marked "completed" still counts as the case's "current" plan, or a uniqueness constraint blocks an otherwise unrelated write.
Problem
When an invariant is guarded only at the UI layer, every non-UI write path persists data that violates it. Because the value is accepted at entry, the failure surfaces far from its cause, and often in a different subsystem. That makes it challenging to associate the symptom with the original bad input, and it lets invalid state propagate to computes, exports, and integrations that all assumed the value was already sound.
We need a single enforcement point that every write path crosses, so the invariant holds regardless of how the data arrived.
Decision
Invariants that must always hold are enforced at the layer every write path crosses: a stored-field constraint (@api.constrains) or a database constraint, not a UI-only hook.
UI hooks may remain, but only as friendlier immediate feedback, never as the sole guard. The authoritative check lives where create() and write() funnel through, so ORM writes, imports, and remote calls all observe it. The execution and display layers downstream may then assume the invariant already holds.
When a newly enforced invariant can be violated by rows that already exist, the code fix is paired with a data migration that repairs those rows. Enforcing forward and repairing history are treated as two separate, both-required jobs.
Alternatives Considered
Guard in the form UI only (@api.onchange)
Rejected.
It protects the form and nothing else. ORM create and write, CSV and Excel import, and API writes all bypass it, so the invariant is absent on exactly the high-volume, unattended paths where bad data is most likely to enter.
Let the display or compute layer cope with bad values
Rejected.
Clamping a negative computed age to zero, for example, hides the symptom while the invalid value stays in the database, and every other consumer still reads the raw value. This treats the readout, not the state, as the thing to fix.
Validate only in a downstream business method (for example, at "apply" time)
Rejected.
It moves the failure to the worst possible moment. A change request carrying a bad date can be entered, reviewed, and approved, then fail on final apply when the value finally reaches the model, rolling back the whole transaction with an error that names no field. Data entry is left free to store the invalid value simultaneously.
Fix only future writes and ignore existing rows
Rejected as incomplete.
A write-time constraint does nothing about rows that are already invalid. Those rows stay broken until next touched, and can then block otherwise unrelated writes. Existing bad data needs its own migration.
Consequences
Positive
One enforcement point covers the form, ORM, import, and remote paths together.
Invalid data is rejected at entry, close to the mistake, instead of surfacing downstream.
Computes, exports, and integrations can assume the invariant holds.
The invariant becomes testable across every write path, and the test can be proven non-vacuous by reverting the fix and confirming it goes red.
Trade-offs
A newly enforced constraint can start rejecting writes that merely touch a pre-existing bad row, so it usually needs a paired migration to repair history before it can be relied on.
Error messages have to name the record and the offending value, or a failed bulk import gives an operator no way to find the bad row.
Date invariants must use the acting user's timezone (context_today) rather than the server date (today), or the guard wrongly rejects valid same-day data for users east of UTC.
A constraint that is stricter than intended can reject legitimate states that fixtures or tests rely on (for example, seeding a record directly in a "completed" state), so the predicate needs to be scoped with care.
Evidence & Related Work
This decision is drawn from configuration, validation, and data-integrity work contributed to OpenSPP (Odoo 19). The items below are concrete applications of the principle in review-tested changes.
Implementation Evidence
OpenSPP #397: reject a future birthdate on every write path
The only guard on res.partner.birthdate lived in a form onchange, so ORM, import, and API writes stored future dates, which the age compute then rendered as a negative string. The fix adds a stored-field @api.constrains so the rule fires on create, write, and import alike.
OpenSPP #478: keep "current" and "completed" coherent
Completing an intervention plan never released its "current" flag, so a finished plan stayed the case's current plan and blocked any successor. The fix folds the flag release into the same write() that every path goes through, adds a post-migration to repair released databases already holding the incoherent pair, and fixes the demo generator that re-seeded the bad state on fresh installs.
OpenSPP #479: protect admin-tuned data records from upgrade resets
A related boundary lesson rather than the core decision: cron and config records shipped without noupdate, so every upgrade overwrote admin-tuned values back to defaults. The fix makes the data file noupdate="1" and reconciles the stored ir.model.data metadata via migration.
Related Reference
Odoo-isms for a Python Person
An internal reference sheet capturing the framework-specific gotchas behind these fixes: the ORM lifecycle, the @api decorators and where each one runs, timezone-aware dates, noupdate, migrations, the OCA release ritual, and testing conventions.
Relationship to the Broader Engineering Approach
This ADR is the persistence-layer application of the same principle expressed in the configuration-validation ADR:
Invalid states should be made difficult or impossible to reach the execution layer.
Where the configuration ADR places validation between external input and execution, this ADR places invariant enforcement between every write path and stored state:
Input (form, ORM, import, RPC) → Constraint at the shared write boundary → Valid stored state → Downstream reads
rather than:
Input → UI-only guard on one path → Invalid stored state → Downstream failure
The same shape recurs elsewhere: API inputs, workflow state transitions, and agent actions all benefit from a single authoritative guard on the path they share, rather than a guard on the one path that happens to be interactive.
Decision Outcome
Always-hold invariants are enforced at the shared write boundary (a stored constraint or a database constraint). UI hooks are retained only as user-experience feedback, never as the sole guard. Newly enforced invariants are paired with a migration that repairs existing rows, and their error messages are written to name the record and value, so failures stay actionable.
This ADR should be revisited if a future requirement makes a single enforcement boundary impractical, or if an invariant genuinely needs to differ by write path.