odoo/odoo · error · UserError

You cannot remove parts of a restricted audit trail. Archive

Error message

You cannot remove parts of a restricted audit trail. Archive the record instead.

What it means

mail.message's audit-log guard (_except_audit_log, @api.ondelete and invoked from write) raises UserError when a message belongs to a restricted accounting audit trail (account_audit_log_restricted) and the related move was not posted before restriction. Restricted audit trails are legally immutable in several localizations, so neither deletion nor edits to body/type/links are allowed. An internal bypass exists via context key 'bypass_audit' matching a secret token, used by Odoo's own rollback flows.

Source

Thrown at addons/account/models/mail_message.py:188

                query = self.env[model]._search(value)
            else:
                query = value
            res_id_domain = [('res_id', 'in' if operator in ('any', 'any!') else 'not in', query)]
        elif operator in ('in', 'not in'):
            res_id_domain = [('res_id', operator, value)]
        else:
            return NotImplemented
        return [('model', '=', model)] + res_id_domain

    @api.ondelete(at_uninstall=False)
    def _except_audit_log(self):
        if self.env.context.get('bypass_audit') is bypass_token:
            return
        for message in self:
            if message.account_audit_log_move_id and not message.account_audit_log_move_id.posted_before:
                continue
            if message.account_audit_log_restricted:
                raise UserError(self.env._("You cannot remove parts of a restricted audit trail. Archive the record instead."))

    def write(self, vals):
        # We allow any whitespace modifications in the subject
        normalized_subject = ' '.join(vals['subject'].split()) if vals.get('subject') else None
        if (
            vals.keys() & {'res_id', 'res_model', 'message_type', 'subtype_id'}
            or ('subject' in vals and any(' '.join(s.subject.split()) != normalized_subject for s in self if s.subject))
            or ('body' in vals and any(self.mapped('body')))
        ):
            self._except_audit_log()
        return super().write(vals)

View on GitHub (pinned to 1e661df964)

Solutions

  1. Do not delete/modify those messages; archive or exclude messages where account_audit_log_restricted is set.
  2. Filter before deletion: msgs.filtered(lambda m: not m.account_audit_log_restricted).
  3. Only if you are implementing an official unpost/rollback flow, pass the internal bypass_audit context token ( Odoo core only).

Example fix

# before
messages.unlink()  # UserError on restricted audit trail

# after
messages.filtered(lambda m: not m.account_audit_log_restricted).unlink()
Defensive patterns

Strategy: validation

Validate before calling

deletable = messages.filtered(
    lambda m: not m.account_audit_log_restricted)

Prevention

When it happens

Trigger: unlink() on mail.message records tied to hashed/restricted moves, or write() touching res_id/res_model/message_type/subtype_id, body of non-empty messages, or changed subjects; e.g. cleanup of chatter, GDPR deletion scripts, or auto-vacuum hooks.

Common situations: Log/data-minimization jobs trying to purge old chatter on closed fiscal years; tests deleting messages on posted entries; modules writing to mail.message fields that trigger the guard.

Related errors


AI-assisted analysis of odoo/odoo@1e661df964 (2026-08-15). Data as JSON: /api/errors/27c75799b0fa3e82. Report an issue: GitHub.