Summary
spp.hide.menu.menu_id carries no uniqueness constraint, and IrModuleModule.hide_menus() reads .state off the result of a search() that can legitimately return more than one row. A second row for the same menu therefore raises ValueError: Expected singleton inside _register_hook, which aborts the registry load — every request returns 500 and the instance becomes completely unreachable.
This is not hypothetical: it took down a DSWD 4Ps development instance today, and the only recovery was hand-editing the database.
The failing code
spp_hide_menus_base/models/ir_module_module.py:
hidden_menus = self.env["spp.hide.menu"].search([("menu_id", "=", menu.id)])
if not hidden_menus:
hidden_menu = self.env["spp.hide.menu"].create({...})
hidden_menu.hide_menu()
elif hidden_menus.state == "show": # <-- ensure_one() on a 2-record set
hidden_menus.hide_menu()
elif hidden_menus.state == "hide":
hidden_menus._reapply_hide()
hide_menus() is called from _register_hook(), which runs at the end of every registry load.
Why a duplicate row appears at all
hide_menus() creates a row for any menu in MENU_APP that does not have one. So every database that has ever booted already carries rows for all 15 mapped menus (mail.menu_root_discuss, calendar.mail_menu_calendar, etc.), created in Python and owned by no module.
A downstream module that seeds an spp.hide.menu record for one of those menus in an XML data file cannot adopt the existing row — its <record> has its own xml_id and no ir.model.data pointing at the Python-created row, so it inserts a second row. Nothing rejects it.
The failure mode is nastily asymmetric:
- Fresh install — the data file loads before the first
_register_hook, so hide_menus() finds the seeded row and skips creating one. Exactly one row. Everything passes.
- Existing database — the row already exists, the seed adds a second, and the next registry load bricks the instance.
So the mistake is invisible to CI and to any install-time test suite, and only manifests on deployment to environments that already have data.
Observed
File "/mnt/extra-addons/openspp/spp_hide_menus_base/models/ir_module_module.py", line 102, in _register_hook
self.hide_menus()
File "/mnt/extra-addons/openspp/spp_hide_menus_base/models/ir_module_module.py", line 80, in hide_menus
elif hidden_menus.state == "show":
File "/opt/odoo/odoo/odoo/orm/fields.py", line 1659, in __get__
record.ensure_one()
ValueError: Expected singleton: spp.hide.menu(2, 23)
2026-08-11 08:58:35,404 1 ERROR ? odoo.registry: Failed to load registry
Database state at the time:
| menu |
row |
ir.model.data |
origin |
76 mail.menu_root_discuss |
2 |
(none) |
hide_menus() auto-created |
| 76 |
23 |
downstream seed |
duplicate |
140 calendar.mail_menu_calendar |
4 |
(none) |
hide_menus() auto-created |
| 140 |
24 |
downstream seed |
duplicate |
Reproduction
- Install
spp_hide_menus_base and let the registry load once (creates the MENU_APP rows).
- Install any module whose data file contains an
spp.hide.menu record referencing e.g. mail.menu_root_discuss.
- Restart. The registry fails to load; every request 500s.
A second, quieter bug in the same area
hide_menu() snapshots the menu's real groups into default_group_ids and collapses group_ids to the hide group. If a row is left in state show while its menu is already collapsed, the next hide_menus() takes the elif ... == "show" branch and re-snapshots default_group_ids from the collapsed group_ids — permanently overwriting the real groups with the hide group. show_menu() then restores nothing, and _reapply_hide() cannot repair it either (its real = group_ids - hide_group is empty, so it skips).
That state is exactly what clearing a duplicate leaves behind if the survivor's state is not reconciled, which makes it a natural consequence of this bug rather than a separate one.
Proposed fix
Two parts, because neither alone is sufficient:
- Make
hide_menus() singleton-safe so no database can be taken down by a duplicate that already exists in the wild. This is the part that matters for instances currently deployed.
- Add
models.Constraint("UNIQUE(menu_id)", ...) to spp.hide.menu, behind a pre-migrate that de-duplicates first, so the state stops recurring. The de-dup must keep the row whose default_group_ids is not merely the hide group — that is the only row that can still restore its menu.
The ordering works out: migrate_module(package, 'pre') (loading.py:174) runs before registry.init_models(...) (:194), where the constraint is applied, so the index lands on already-clean data.
I have a PR ready implementing both, and will link it here.
Impact
Any layer-2/3 module can trip this, and the consequence is a total outage rather than a degraded feature. The downstream fix (dropping the colliding seed) protects one repo; the constraint protects everyone.
Summary
spp.hide.menu.menu_idcarries no uniqueness constraint, andIrModuleModule.hide_menus()reads.stateoff the result of asearch()that can legitimately return more than one row. A second row for the same menu therefore raisesValueError: Expected singletoninside_register_hook, which aborts the registry load — every request returns 500 and the instance becomes completely unreachable.This is not hypothetical: it took down a DSWD 4Ps development instance today, and the only recovery was hand-editing the database.
The failing code
spp_hide_menus_base/models/ir_module_module.py:hide_menus()is called from_register_hook(), which runs at the end of every registry load.Why a duplicate row appears at all
hide_menus()creates a row for any menu inMENU_APPthat does not have one. So every database that has ever booted already carries rows for all 15 mapped menus (mail.menu_root_discuss,calendar.mail_menu_calendar, etc.), created in Python and owned by no module.A downstream module that seeds an
spp.hide.menurecord for one of those menus in an XML data file cannot adopt the existing row — its<record>has its own xml_id and noir.model.datapointing at the Python-created row, so it inserts a second row. Nothing rejects it.The failure mode is nastily asymmetric:
_register_hook, sohide_menus()finds the seeded row and skips creating one. Exactly one row. Everything passes.So the mistake is invisible to CI and to any install-time test suite, and only manifests on deployment to environments that already have data.
Observed
Database state at the time:
ir.model.datamail.menu_root_discusshide_menus()auto-createdcalendar.mail_menu_calendarhide_menus()auto-createdReproduction
spp_hide_menus_baseand let the registry load once (creates theMENU_APProws).spp.hide.menurecord referencing e.g.mail.menu_root_discuss.A second, quieter bug in the same area
hide_menu()snapshots the menu's real groups intodefault_group_idsand collapsesgroup_idsto the hide group. If a row is left in stateshowwhile its menu is already collapsed, the nexthide_menus()takes theelif ... == "show"branch and re-snapshotsdefault_group_idsfrom the collapsedgroup_ids— permanently overwriting the real groups with the hide group.show_menu()then restores nothing, and_reapply_hide()cannot repair it either (itsreal = group_ids - hide_groupis empty, so it skips).That state is exactly what clearing a duplicate leaves behind if the survivor's
stateis not reconciled, which makes it a natural consequence of this bug rather than a separate one.Proposed fix
Two parts, because neither alone is sufficient:
hide_menus()singleton-safe so no database can be taken down by a duplicate that already exists in the wild. This is the part that matters for instances currently deployed.models.Constraint("UNIQUE(menu_id)", ...)tospp.hide.menu, behind a pre-migrate that de-duplicates first, so the state stops recurring. The de-dup must keep the row whosedefault_group_idsis not merely the hide group — that is the only row that can still restore its menu.The ordering works out:
migrate_module(package, 'pre')(loading.py:174) runs beforeregistry.init_models(...)(:194), where the constraint is applied, so the index lands on already-clean data.I have a PR ready implementing both, and will link it here.
Impact
Any layer-2/3 module can trip this, and the consequence is a total outage rather than a degraded feature. The downstream fix (dropping the colliding seed) protects one repo; the constraint protects everyone.