Upgrade guide
We'll update this page when we release new Avo 4 versions.
If you're looking for the Avo 3 to Avo 4 upgrade guide, please visit the dedicated page.
How to read this page
Entries are in release order, newest first. Find the release you're on, then work up the page to the one you're installing, doing each entry for the gems you use.
To see which versions you run, open your app's status page at /avo/status (or /<your mount path>/status). It lists every Avo gem you have installed and marks the ones with a newer release.
To open this page at your release, add ?since= with a date or a gem version: upgrade.html?since=2026-10-14 or upgrade.html?since=avo@4.3.2.
avo-audit_logging 4.4.2 Unreleased
-> Changes made outside Avo are logged by default
Breaking Change
outside_avo now defaults to true. A resource that opts in with self.audit_logging logs every create, update and delete on its model wherever it happens, such as in a job, an importer, the console or your app's own pages. It needs PaperTrail on the model, so a model without has_paper_trail logs what it did before. In the panel, a create's entry now links its version, so its page shows what the record was created with. See Log changes made outside Avo.
Action Required
Check each resource that opts in and whose model has PaperTrail.
If your code calls Avo::AuditLogging.record after its own saves, each of those changes is now logged twice. Wrap the save and the call in Avo::AuditLogging::Recorder.claiming, so the call's entry takes the save's version, or set outside_avo: false on the resource as shown below.
# app/services/price_updater.rb
Avo::AuditLogging::Recorder.claiming do
product.update!(price: price)
Avo::AuditLogging.record(action: :update, record: product, author: user, payload: {price: price})
endAn activity block now also runs for changes made outside Avo, where current_user is nil. A block that calls a method on it, such as current_user.audit_avo_activity?, raises there. The change is still saved, but nothing is logged and the error goes to your log. Make the block handle nil. With &. it skips those changes, and current_user.nil? || current_user.audit_avo_activity? logs them.
# app/avo/resources/product.rb
self.audit_logging = {
activity: -> { current_user.audit_avo_activity? }
activity: -> { current_user&.audit_avo_activity? }
}Expect more entries in the audit log, one for each change your jobs and imports make. Your test suite logs the records its setup creates too, so a spec that counts entries may count more. Wrap work you don't want logged, such as an import or a factory call, in Avo::AuditLogging.disabled { ... }.
Maintaining Previous Behavior
Set outside_avo: false on each resource that should log only what happens in Avo.
# app/avo/resources/product.rb
class Avo::Resources::Product < Avo::BaseResource
self.audit_logging = {
activity: true,
outside_avo: false
}
endavo-ai 4.4.1 Unreleased
-> The chat bar yields to mobile navigation
Opening the sidebar on smaller screens temporarily hides the floating chat bar so it cannot cover the profile menu. Closing the sidebar restores the bar, including the current conversation and draft. Desktop navigation is unchanged. See Open the chat.
Action required: None.
avo-audit_logging 4.3.3 Unreleased
-> Audit logging is configured inside Avo.configure
Deprecation
The audit logging options moved under config.audit_logging in Avo.configure. The standalone Avo::AuditLogging.configure block still applies its options, but logs a deprecation warning on boot. It will be removed in Avo 5.
Action Required
Only if config/initializers/avo.rb (or another initializer) calls Avo::AuditLogging.configure. Move each option into Avo.configure under config.audit_logging and delete the old block. The options and their values are unchanged: enabled, author_model, author_models and payload_modifier.
# config/initializers/avo.rb
Avo.configure do |config|
# ...
config.audit_logging.enabled = true
config.audit_logging.author_models = ["User", "Account"]
end
Avo::AuditLogging.configure do |config|
config.enabled = true
config.author_models = ["User", "Account"]
endCode that reads Avo::AuditLogging.configuration keeps working and returns the same object as Avo.configuration.audit_logging.
avo-notifications 4.3.2 Unreleased
-> Notifications are configured inside Avo.configure
Deprecation
The notifications options moved under config.notifications in Avo.configure. The standalone Avo::Notifications.configure block still applies its options, but logs a deprecation warning on boot. It will be removed in Avo 5. The installer no longer writes config/initializers/avo_notifications.rb; it adds the options to config/initializers/avo.rb instead.
Action Required
Only if config/initializers/avo_notifications.rb (or another initializer) calls Avo::Notifications.configure. Move each option into Avo.configure under config.notifications and delete the old block, or the whole file if nothing else is in it. The options and their values are unchanged: ttl, realtime, dropdown_limit, user_class and user_display_name_method.
# config/initializers/avo.rb
Avo.configure do |config|
# ...
config.notifications.ttl = 7.days
config.notifications.realtime = false
end
# config/initializers/avo_notifications.rb
Avo::Notifications.configure do |config|
config.ttl = 7.days
config.realtime = false
endCode that reads Avo::Notifications.configuration keeps working and returns the same object as Avo.configuration.notifications.
avo-meta 0.1.3 Unreleased
-> Meta is configured inside Avo.configure
Deprecation
The fields_position option moved under config.meta in Avo.configure. The standalone Avo::Meta.configure block still applies it, but logs a deprecation warning on boot. It will be removed in Avo 5.
Action Required
Only if an initializer calls Avo::Meta.configure. Move the option into Avo.configure and delete the old block.
# config/initializers/avo.rb
Avo.configure do |config|
# ...
config.meta.fields_position = :top
end
Avo::Meta.configure do |config|
config.fields_position = :top
endavo-dynamic_filters 4.3.3 Unreleased
This release adds three things to the filter bar. None of them change your app until you turn them on.
- Pinned filters.
filterable: {pinned: true}keeps a filter's pill in the bar, so people can use it without opening "Add filter" first. - The new filter dropdown.
config.dynamic_filters.simple_filters = truegives each filter type its own dropdown: a condition menu, then a checklist, a row of buttons, date presets or a box. It filters as you click, with no Apply button. It is off by default and becomes the default in a future release. - Inline filters. With the new dropdown on,
filterable: {pinned: true, inline: true}, orconfig.dynamic_filters.inline_filters = truefor every pinned filter, puts a filter's control straight in the bar, like Yes / No buttons for a boolean. Date filters also takepresets, andnullable: trueadds an Empty choice.
-> Turn on the new filter dropdown and inline filters (recommended)
What changed
The new dropdown and inline filters ship off, so an app keeps the older dropdown until it opts in. The new dropdown becomes the default in a future release.
Action Required
Turn both on:
# config/initializers/avo.rb
Avo.configure do |config|
# ...
config.dynamic_filters.simple_filters = true
config.dynamic_filters.inline_filters = true
endinline_filters only changes pinned filters. If the app pins none, pin the two or three filters its users reach for most, with filterable: {pinned: true} on those fields.
Then run your system tests. A test that picks a condition from a select, types a value and clicks Apply drives the older dropdown: update it to pick from the condition menu and the value control, as The filter dropdown describes. If you can't update them yet, keep it on everywhere but the test environment with config.dynamic_filters.simple_filters = !Rails.env.test?.
-> Filter pills show the name, condition and value as separate segments
What changed
A pill reads Name | is | Austin rather than Name is "Austin", without the quotes. "Add filter" and "Reset filters" sit after the pills. This reaches every app, with or without the options above.
Action Required
None, unless a system test or custom CSS relies on the old pill text or on the position of those two buttons.
-> Several negative values on one filter exclude all of them
What changed
Several Is not or Does not contain values on one attribute are now combined with AND, as How conditions combine describes. They used to be combined with OR, which matched nearly every record. This reaches every app.
Action Required
None, unless you link to a filtered URL with several negative values. It now returns fewer records.
-> Dynamic filters are configured inside Avo.configure
Deprecation
The dynamic filters options moved under config.dynamic_filters in Avo.configure, the same way config.ai and config.mcp_server work. The standalone Avo::DynamicFilters.configure block still applies its options, but logs a deprecation warning on boot. It will be removed in Avo 5.
Action Required
Only if config/initializers/avo.rb calls Avo::DynamicFilters.configure. Move each option into Avo.configure under config.dynamic_filters, and delete the old block with its if defined?(Avo::DynamicFilters) guard. The options and their values are unchanged: button_label, always_expanded and param_key.
# config/initializers/avo.rb
Avo.configure do |config|
# ...
config.dynamic_filters.button_label = -> { I18n.t("my.filters") }
config.dynamic_filters.always_expanded = false
end
if defined?(Avo::DynamicFilters)
Avo::DynamicFilters.configure do |config|
config.button_label = -> { I18n.t("my.filters") }
config.always_expanded = false
end
endCode that reads Avo::DynamicFilters.configuration keeps working and returns the same object as Avo.configuration.dynamic_filters.
avo-mcp_server 4.3.0.beta.3 Unreleased
-> ConnectionPolicy#use? turns the server on or off per admin
Breaking Change
The server now asks use? on Avo::McpServer::ConnectionPolicy before it serves an admin: on every call, on code exchange and refresh, on the consent screen and on the connect page. A ConnectionPolicy without use? turns the server off for every admin. See Turn MCP on or off per admin.
Action Required
If your app has an Avo::McpServer::ConnectionPolicy, add use? to it:
# app/policies/avo/mcp_server/connection_policy.rb
class Avo::McpServer::ConnectionPolicy < ApplicationPolicy
def use? = true
endIf it has none, Avo's defaults decide, as for any missing policy. Under explicit_authorization = true, which is Avo's default, the server is off for every admin until you add the policy above. Under explicit_authorization = false it stays on. With raise_error_on_missing_policy = true, the missing policy raises on the server's endpoints instead. The policy also decides who sees the connections resource, so see Decide who sees and revokes what for its other methods.
With config.authorization_client = nil no policy runs: the server stays on, use? is never asked, and nobody is blocked.
With avo-permissions as your authorization client, no ConnectionPolicy is asked and explicit_authorization doesn't apply: a role needs a use? permission on Avo::Resources::McpConnection, or the server is off for it. The permissions screen doesn't offer it yet, so add the row from a console for each role that should keep MCP:
Avo::Permissions::Permission.create!(role: role, resource_class: "Avo::Resources::McpConnection", method: "use?")-> A tool of your own declares the resources it touches
Breaking Change
A connection narrowed to named resources now refuses a call to a tool of your own that declares no resources and sends no resource argument its schema declares, because the server can't tell what it reads. Before, such a call ran on those connections. On every connection, a tool of your own is now also refused when the connecting admin's index? refuses a resource it declares or names, and when its resource argument matches no resource. See Add tools of your own.
Action Required
Only if you register tools through config.mcp_server.extra_tools. Declare the resources each one reads or changes. A tool that reaches no resource at all, say one that only calls an outside API, has nothing to declare, so it can't run on a narrowed connection. With avo-permissions as your authorization client, a call that names a resource, through resources or a resource argument, is refused on every connection whatever rows the role holds, because the server's policy checks don't pass that client the resource its rows match on. The shipped tools already behave this way, and list_resources and search_records with no resource answer empty there. A tool with neither still runs on a connection that isn't narrowed.
# app/tools/ticket_summary_tool.rb
class TicketSummaryTool < Avo::McpServer::Tool
capability "avo:read"
resources "Ticket", "Project"
end
# Or, on a RubyLLM::Tool shared with Avo AI:
def self.resources = %w[Ticket Project]avo-advanced_search 4.3.3 Unreleased
-> Global search shortcuts target the navbar and results-page inputs separately
Breaking Change
On the dedicated results page, Mod+k now focuses the navbar search, just as it does elsewhere. It no longer focuses the page input on the first press and the navbar on a double-tap. Use Mod+Shift+K to focus the page input instead. See Global search.
Action Required
Action required: None. To customize either shortcut, use advanced_search.open for the navbar and advanced_search.focus_results for the page input in config.hotkeys[:bindings].
avo-api 4.4.0 Unreleased
-> belongs_to and has_one ids are the related record's to_param
Breaking Change
A belongs_to or has_one field now reads back with the related record's to_param, the id in its panel URL, instead of its primary key. It's always a string: { "id": "5", "label": "John Doe" }, not { "id": 5, "label": "John Doe" }. Where a model hides its primary key (friendly_id, a prefixed id, a custom to_param), the response carries the slug or prefixed id instead of the key. The OpenAPI document types both ids as strings. See Field serialization.
A belongs_to write takes that value back, also on a resource found by slug alone. A record's own id field doesn't change.
Action Required
Update API clients that read these ids as numbers or compare them with numbers. Send back the value a read returns.
avo-ai 4.3.0.beta.10 Nov 9, 2026
-> ChatPolicy#use? turns the chat on or off per user
Breaking Change
The chat now asks use? on Avo::Ai::ChatPolicy before it serves a user: on the chat bar, on every chat page and request, and in a reply or title already queued. The policy avo-ai ships answers it for every signed-in user, but a ChatPolicy of your own without use? turns the chat off for everyone. See Turn the chat on or off per user.
Action Required
If your app has its own Avo::Ai::ChatPolicy, add use? to it:
# app/policies/avo/ai/chat_policy.rb
class Avo::Ai::ChatPolicy < ApplicationPolicy
def use? = user.present?
endWith avo-permissions as your authorization client, a role needs a use? permission on Avo::Resources::AvoAi::Chat to keep the chat, and the permissions screen doesn't offer it yet. Add the row from a console for each role that should keep the chat:
Avo::Permissions::Permission.create!(role: role, resource_class: "Avo::Resources::AvoAi::Chat", method: "use?")With config.authorization_client = nil no policy runs: the chat stays on, use? is never asked, and nobody is blocked.
avo-dashboards 4.3.4 Nov 9, 2026
-> Cards load when they scroll into view
Breaking Change
Cards used to load as soon as the page rendered, all at once. Now a card waits until it scrolls into view, so a card further down a long dashboard or resource page runs its query only when the viewer reaches it. See Load cards only when they're needed.
Action Required
Action required: None. If your system tests assert on a card below the fold, scroll to it first.
-> A dashboard's actions method now fills an Actions dropdown in its header
Breaking Change
Dashboards can now run actions. Avo calls a dashboard's actions method on every render and shows what it declares in an Actions dropdown. A dashboard that already defines actions for its own purposes now has that method called, and action or divider inside it mean something new.
To run those actions, a dashboard now also answers to action, get_actions, visible_actions, find_action, view, view_type, records_path, record, user, params, model_key, route_key, authorization, hydrate, resource_name_for, translation_key and get_stimulus_controllers. A method of yours with one of these names replaces Avo's, and an action started from that dashboard uses yours.
Action Required
Action required: Only if a class in app/avo/dashboards defines one of these methods. Otherwise, none.
Steps to Update
Rename your method.
# app/avo/dashboards/operations.rb
def actions
def queue_actions
# ...
endavo 4.3.11 Nov 4, 2026
-> Session persistence also keeps the sort, the resource table page, and the selected rows
Breaking Change
With config.persistence = { driver: :session }, the session used to keep only association pagination and static filter selections. It now also keeps the page and per_page of resource tables (the per_page cookie is no longer read), the sort picked from a table header, and the rows checked on a table, which stay in the browser tab. Dynamic filter selections are kept too, with avo-dynamic_filters 4.3.2 or newer. A plain visit, such as the sidebar link, lands on the remembered page, sorted and filtered as the user left it, with the rows still checked. Nothing changes without the driver. See Persist UI state.
Action Required
None, if you want the new state to stick. Check your system specs: one that sorts, pages, or selects rows with the driver on and then expects a plain visit to start fresh now finds that state still there.
Maintaining Previous Behavior
Turn the new kinds off next to the driver. pagination covers resource and association tables together, so association pagination alone can't be kept.
# config/initializers/avo.rb
Avo.configure do |config|
config.persistence = {
driver: :session,
sorting: false,
selection: false
}
endavo 4.3.9 Oct 28, 2026
-> act_on? authorizes running an action, not only the Actions button
Breaking Change
Running an action now asks the resource policy's act_on? of the record on the page and of each selected record (the model class when there are none, and for Select all). If any is denied, the whole run is refused. Before, act_on? only hid the Actions button, so a hand-built request could run an action on records the policy denies.
An action run from an association panel is still shown or hidden by the parent's act_on_{association}?, and now also needs act_on? on each selected child.
Action Required
None, unless a resource's actions run only from association panels and its policy has no act_on?. Under explicit_authorization (the default), those runs are now refused. Define it on the child's policy:
# app/policies/comment_policy.rb
class CommentPolicy < ApplicationPolicy
def act_on?
user.admin?
end
end-> A refusal with no page to go back to renders a 403 page
Breaking Change
A refused request with no page to go back to (an address typed in, a bookmark, a reload) now gets a 403 page in the Avo layout with the "not authorized" message, instead of a redirect to Avo's home page. JSON and Turbo Stream requests get a bare 403. A request with a page to go back to is still redirected there with the flash.
Before, a refused home page redirected to itself until the browser gave up, and a refusal inside an add-on's own pages raised a 500.
Action Required
None, unless a test or client expects the redirect to the home page.
avo-authorization 4.3.3 Oct 28, 2026
-> An app with an authorization client and no avo-authorization license refuses every resource
Breaking Change
When your initializer sets authorization_client and your license doesn't include the avo-authorization add-on, Avo now refuses every resource: lists come back empty, show and edit answer 404, and a banner explains why. Before, every policy was skipped and every admin reached every record. See When the license lapses.
Action Required
Check that every environment where your initializer sets a client runs with your license key, test and CI included. Apps with no authorization_client line, or with it set to nil, are unaffected.
avo-ai 4.3.0.beta.8 Oct 28, 2026
-> The assistant reads a record's fields and files only when show? allows it
Breaking Change
The assistant now asks show? before it reads a record's field values, its files, or its chip fields. A row the user may list but not open comes back with only its id and reference. Before, index? and the policy scope were enough.
Action Required
None, unless users should read rows through the chat that their show? refuses. Widen show? for them.
-> The **Agent** button and the new-chat page render _pit_core_icon
Breaking Change
The Agent button and the full-page new-chat page now render avo/ai/_pit_core_icon instead of avo/ai/_avocado_icon. An app that replaced the assistant's icon with its own _avocado_icon partial still changes the empty states and the Open in assistant bar button, but these two go back to Avo's own mark. See Replace the assistant's icon.
Action Required
None, unless you override _avocado_icon. If you do, override _pit_core_icon as well, passing classes through:
<%# app/views/avo/ai/_pit_core_icon.html.erb %>
<%= svg "tabler/outline/robot", class: local_assigns[:classes] %>avo-api 4.3.1 Oct 28, 2026
-> Association fields follow view_{association}? and the related policy scope
Breaking Change
An association field is now left out of a response when the record's policy refuses view_<field>?, or, under explicit_authorization (the default), doesn't define it. Counts and has_one ids go through the related resource's Scope, so a has_one outside it is null. A has_and_belongs_to_many field is now { "count": 12 }, like a has_many, instead of the related rows. See Field serialization.
Action Required
Define view_<field>? for every association field your API clients read, and update clients that read a has_and_belongs_to_many field as a list.
# app/policies/user_policy.rb
class UserPolicy < ApplicationPolicy
def view_posts? = true
endavo-audit_logging 4.3.1 Oct 28, 2026
-> Undo needs update? on the record
Breaking Change
The Undo change action now asks the record's policy: update?, with the record found through its policy scope, or create? to bring back a destroyed record. Users the policy refuses no longer see the action. The activity's payload also leaves out fields the user's field policy withholds, as the changeset diff already did.
Action Required
None, unless an STI model records its versions under a base class that has no Avo resource. Undo is hidden for those until you map the base class with model_resource_mapping.
avo-collaboration 4.3.2 Oct 28, 2026
-> Timeline requests need access to the record and its timeline
Breaking Change
Every collaboration request now finds the record through the resource's policy scope and asks show? and collaboration_view_timeline?. Posting also asks collaboration_create_entry?, and deleting asks collaboration_destroy_entry?. Before, these methods only hid buttons. A refused request gets a 403, and a record outside the scope a 404. See Authorization.
Action Required
None. The timeline's buttons already followed these methods.
avo-dashboards 4.3.2 Oct 28, 2026
-> A card's URL refuses what its page refuses
Breaking Change
A card's own URL now refuses a card of a dashboard whose authorize fails, a card hidden by visible, and a resource card whose policy refuses index? (or show? on the record). Before, it served any card by its position. See Cards visibility.
Action Required
None. Cards render on their pages as before.
avo-dynamic_filters 4.3.1 Oct 28, 2026
-> A filter applies only the conditions it offers
Breaking Change
A condition in the URL that the filter doesn't offer is now ignored, and no pill shows for it. Before, Avo passed any condition to Ransack. That includes is_present and is_blank on number and select filters, which only text filters offer. See conditions.
Action Required
None, unless a link or bookmark uses a Ransack predicate the filter doesn't list. Add it to the filter's conditions, together with the default conditions you want to keep.
avo-http_resource 4.3.1 Oct 28, 2026
-> The debug console runs a probe only when the policy allows that action
Breaking Change
Each debug console probe now asks the policy method the resource's own page would: index? for index and count, then show?, create?, update?, and destroy? for delete. A refused probe is reported as not authorized and never sent. Before, index? was enough to run every probe, writes to the remote API included.
Action Required
None, unless the console's users should run write probes. Allow create?, update?, or destroy? for them. These are asked of the model class, so a method that reads the record refuses.
avo-kanban 4.3.2 Oct 28, 2026
-> Board routes follow the board policy, and cards their record's policy
Breaking Change
Every board route now asks the Avo::Kanban::BoardPolicy method behind its button and finds the board through the policy's Scope. Removing a card asks manage_column?, and resetting a column is now a POST. Moving or adding a card updates its record, so it needs the record inside its resource's policy scope, update? on it, and a field on that resource that writes the board's property. Cards whose record the scope hides are no longer shown. See Authorization.
Action Required
- Allow
update?on the records your users move on a board. - If a board's property is a virtual attribute with no field, add one to the resource. See Items without that property.
avo-mcp_server 4.3.0.beta.2 Oct 28, 2026
-> Association values follow view_{association}? and the related policy scope
Breaking Change
The tools now leave out an association the record's policy refuses with view_<association>?, or, under explicit_authorization (the default), doesn't define. A has_one is read through the associated resource's policy scope and is null outside it, and has_many ids go through that resource's scope. See Arguments.
Action Required
Define view_<association>? for every association MCP clients should read.
records are named by to_param
-> Record ids are strings, resolved through the resource's own finder
What changed
The MCP tools name every record by its to_param, the id in the panel's URL, and find it through the resource's find_record_method, within the admin's policy scope. They used to use the primary key.
- Ids are strings.
list_records,search_records,show_recordand the write tools return a record's id as"12", not12, and so do abelongs_to's id, ahas_many's ids, andrun_action'srecordIds. Theidandrecord_idsarguments take strings; an integer is refused withvalue at /id is not a string. - Hidden keys stay hidden. Where a model hides its primary key (friendly_id, hashids, a prefixed id, a custom
to_param), tools return the slug or hashid instead of the primary key, and take whatever the resource's finder takes. A record with no title attribute is titledPost #<to_param>. - A
belongs_totakes back the idshow_recordgives, also on a resource found by slug alone.
Action Required
- A client, prompt or skill that sends or compares ids as numbers: treat ids as strings and send back what the tools return.
- A tool of your own: declare its id arguments
type: "string", pass them toToolSupport.find_authorized_record!as received, and name records byrecord.to_paramin what it returns. See Add tools of your own.
avo-menu 4.3.1 Oct 28, 2026
-> A menu action item shows only when the resource's policy allows act_on?
Breaking Change
A menu action item now shows only when the resource's policy allows act_on?, as the resource's own Actions dropdown does. Under explicit_authorization (the default), a policy without act_on? hides it.
Action Required
None, unless the resource's policy lacks act_on?. Define it.
-> all_boards lists only the boards the board policy's Scope returns
Breaking Change
all_boards now lists only the boards Avo::Kanban::BoardPolicy::Scope returns. Before, it listed every board.
Action Required
None.
avo-nested 4.3.2 Oct 28, 2026
-> A nested form writes only the children the user may update or create
Breaking Change
Saving a parent with a nested form now asks the child's policies. An existing child is written only when it's inside the child resource's policy scope and update? allows it; other rows are skipped. A new child needs the child's create? and the parent's create_<association>?, and the "Add" button is hidden when create_<association>? refuses. Under explicit_authorization (the default), a parent policy without create_<association>? refuses new rows. See Nested in Forms.
Action Required
Define create_<association>? on the parent's policy for every nested association users add records to:
# app/policies/book_policy.rb
class BookPolicy < ApplicationPolicy
def create_authors? = user.admin?
endavo-notifications 4.3.1 Oct 28, 2026
-> The notification resource finds records through the current user
Breaking Change
The generated notification resource scoped the list to the current user, but not the record lookup. A signed-in user could open, delete or run actions on another user's notification by id. New installs generate a resource that scopes both. See Notification resource.
Steps to Update
If you generated app/avo/resources/avo_notification.rb with avo-notifications 4.3.0 or earlier, add the lookup below index_query:
# app/avo/resources/avo_notification.rb
class Avo::Resources::AvoNotification < Avo::BaseResource
self.index_query = -> {
query.for_user(Avo::Current.user).newest_first
}
self.find_record_method = -> {
query.for_user(Avo::Current.user).find(id)
}
endavo-record_reordering 4.3.1 Oct 28, 2026
-> Moving a record needs reorder? on its policy
Breaking Change
Moving a record now asks reorder? on its own policy, from any panel. Association panels still show the controls by reorder_<association>?, but the move also needs the record's reorder?. After a move, Avo redirects back only to an address inside the app.
Action Required
Define reorder? on the policy of every resource users reorder from an association panel:
# app/policies/course_link_policy.rb
class CourseLinkPolicy < ApplicationPolicy
def reorder? = user.admin?
endavo-scopes 4.3.1 Oct 28, 2026
-> A scope count asks the same policy as the index it counts
Breaking Change
A scope's count now asks index?, or on an association panel the parent's view_<association>?. A refused count renders an empty pill. Before, a user could read the counts of a resource whose index their policy denies.
Action Required
None. A user who can see the index sees the same counts.
avo 4.3.8 Oct 28, 2026
This release closes places where a signed-in user could reach a record past the policy the resource's own pages ask (GHSA-5qcq-2vq9-62hj). Each entry below is a policy that now runs where none ran before, so read them against the policies you have.
-> update? is asked of the record as stored and again with the submitted values
Breaking Change
Saving an edit now asks update? twice: once with the record as it is stored, then again once the submitted values are assigned. Both have to pass. Before, only the filled record was authorized, so a value the user submitted could make a record pass a policy it fails as stored, such as a new owner under an owner-only update?. A react_on re-render of an open edit form asks edit? the same way.
Action Required
None for a policy that answers from the user and the record's stored values. A policy that passes only once the user submits a value is now refused by the first check, and a policy that does work of its own does it twice.
# app/policies/project_policy.rb
class ProjectPolicy < ApplicationPolicy
# Asked before and after the form's values land on the record, so submitting
# your own id as the new owner no longer makes this pass.
def update?
record.user_id == user.id
end
end-> Listing a parent's association by URL asks view_{association}?
Breaking Change
Every request that lists a parent's association now asks the parent policy's view_{association}?, as the panel itself does: the panel's rows, its search, the chart of a summarizable column in it, and scope counts under it. The request also has to name the resource the association field lists with, and answers 404 otherwise. Before, these requests listed a parent's association under any resource, whatever the parent's policy said.
Action Required
None in most apps: a panel is only shown when view_{association}? allows it, so the same method now answers the requests behind it. Two cases to check:
- A link or bookmark you build by hand to an association list has to name the field's resource, which is its
use_resourcewhen the field sets one. - Under
explicit_authorization(the default), a parent policy with noview_{association}?refuses these requests, as it already hid the panel.
# app/policies/post_policy.rb
class PostPolicy < ApplicationPolicy
def view_comments? = true
end-> A summarizable column's chart asks index?
Breaking Change
The distribution chart behind a summarizable column ran no policy check. It now asks index?, or, for a column on an association panel, the parent's view_{association}?. Its rows were already narrowed by the policy Scope; what changes is that a user whose index? refuses the list no longer gets its distribution either.
It asks index? and not a chart method of its own: distribution_chart? and distribution_chart_full? are not policy methods Avo asks for, explicit_authorization included.
Action Required
None. A user who can open the index sees the same chart.
-> A has_one panel reads its record through the related policy scope, and detach clears only the attached record
Breaking Change
The request that renders a has_one panel now finds its record through the related resource's Scope, and answers 404 for a record outside it. Detaching a has_one clears the association only when the URL names the record currently attached, and answers 404 otherwise, so a detach request can no longer clear the association by naming some other record.
Action Required
None, unless a related resource's Scope hides records your users open from a has_one panel. Widen that Scope for them.
avo 4.3.6 Oct 27, 2026
-> Trix uploads on the New form are authorized with create?
What changed
A Trix field on an Action Text attribute, or on a plain column with attachments_disabled: false, now accepts uploads on the New form. Those uploads are authorized with the resource policy's create?. Uploads on a saved record are still authorized with update?, and attachment_key uploads with upload_{FIELD_ID}?.
Action Text attachments, images and other files, now keep their sgid and render on the Show view through your app's active_storage/blobs/_blob.html.erb partial. Before, non-image files rendered as missing (#2715).
Action Required
None, unless your create? policy is looser than update? and you want to keep users who can create a record from uploading files before it's saved. Set attachments_disabled: true on that field.
avo-ai 4.3.0.beta.6 · avo-mcp_server 4.3.0.beta.2 · avo-menu 4.3.1 Oct 27, 2026
an action's visible block applies everywhere
-> An action hidden by self.visible is hidden from the assistant, MCP clients and the menu
What changed
The assistant, the MCP tools and a menu action item now check an action's self.visible block as well as its self.authorize, the way the panel's Actions dropdown does.
- The assistant and
list_actionsleave out an action that is hidden on both the index and a record's page, and refuse to run one that is hidden on the page the run starts from: a record's page for one record, the index otherwise. - A menu
actionitem is left out when its action is hidden on the resource's index.
Action Required
None, unless an action hides itself with self.visible but is still meant to run from the assistant, an MCP client or the menu. Move that rule into self.authorize, or widen self.visible.
avo 4.3.5 Oct 24, 2026
bulk actions find the selected records through find_records
-> query is an Array, in the order the records were selected
What changed
A bulk action now looks up the records the user selected through the resource's new find_records, the same lookup the assistant and the MCP tools use.
queryis an Array, in the order the records were selected. It used to be anActiveRecord::Relationon a resource whosefind_record_methodreturnsquery.where(...)for a list of ids, FriendlyId resources included. Select all still passes a relation.- A
find_record_methodthat ignoresquerystays inside the user's policy scope. The records it returns that the scope doesn't hold drop out ofquery. - A
find_record_methodthat takes one id at a time works for bulk actions. Avo asks it once per id when it fails on the whole list.
Action Required
None, unless a bulk action calls a relation method such as update_all, where or find_each on query. Iterate over it instead, or build a relation with Model.where(id: query.map(&:id)).
attaching a has_many, has_one or HABTM record follows attach_scope
-> The attach modal saves only the records its list offers
What changed
The attach modal of a has_many, has_one or has_and_belongs_to_many field listed the records its attach_scope returned, but saving looked the chosen record up through the policy scope alone. A record outside a narrowing attach_scope could still be attached by sending its id, and one a widening attach_scope listed was refused. Saving now goes through the same attach_scope as the list, the way a belongs_to already did. A record outside it is refused like an id that doesn't exist.
Action Required
None for the modal's own list, which offers only records inside the attach_scope. A searchable picker backed by a custom search provider returns hashes, which the attach_scope can't filter, so make that search return only records inside it. Anything that posts ids to the attach endpoint directly gets the same check.
a belongs_to value that matches no record is a form error
-> The form comes back with the error on the field, and the REST API answers 422
What changed
A belongs_to value that matches no record the field may attach, because the record is gone or outside the field's attach_scope, used to answer with a 404 page, and avo-api with a 404. The form now comes back with "is invalid" on that field, and the API answers 422 with the field named in errors, the way a validation error does. Nothing is saved.
Action Required
None, unless an API client reads a 404 from a create or update as "the related record doesn't exist". It gets a 422 now; read its errors.
-> A form with an unresolved belongs_to keeps the user's other edits and saves no uploads
What changed
The form that comes back with an unresolved belongs_to keeps every other edit the user made, and puts "is invalid" on every belongs_to the submit couldn't resolve, not only the first one. A files field's uploads are now saved with the record rather than while the form is filled, so a form that answers 422 stores no files.
Action Required
None.
a field declared once per view saves through the form's declaration
-> Save rules come from the declaration the form shows
What changed
When a resource declares the same field more than once for different views, saving the form now goes through the declaration the form shows. Before, the last declaration wrote the value, whichever view it was for, so the form's attach_scope, update_using and other save rules could be skipped. See Show and hide fields on different views.
A money field that receives only one of its params now keeps the record's current value for the other, and a polymorphic belongs_to that receives only its <as>_id keeps the current type. An <as>_type sent alone keeps the current record when it is the current type. A new type needs its <as>_id, since the current id would name a different record: a linked record sent a new type alone gets "is invalid" on the field, and nothing is saved. Before, an <as>_id or a currency sent alone was dropped, an <as>_type sent alone cleared the id, and an amount sent alone cleared the currency. The panel's forms always send both; this shows in the REST API, the assistant and MCP clients.
Avo::BaseResource#fields_by_database_id is replaced by fields_by_param, which maps every form param to the field that writes it.
Action Required
Search your resources for a field declared more than once. Where the declarations have different save rules, the one on the form now applies. Replace any call to fields_by_database_id with fields_by_param.
avo-ai 4.3.0.beta.5 · avo-mcp_server 4.3.0.beta.2 Oct 24, 2026
writes go through the resource's fields
-> Creates and updates run each field's save rules and take the form's names
What changed
The assistant and connected MCP clients used to write the values they were given straight onto the model. They now save the way the panel's edit form does, through your fields:
- Save rules run:
update_using, abelongs_tolooked up through its resource's finder and attach scope, a polymorphic field'stypes, and a key-value field's locks. - Names are the form's: a field's id, or the param the form submits. A column a field writes under another name is refused by name:
tag_list(writetags), a money field'sprice_cents, a location'sstored_ascolumns, afor_attribute:field's attribute. On the MCP server, so is a field the edit form doesn't render, such as a badge. - Paired params: a money field with
enable_currencytakes its amount and currency, either of which sent alone keeps the record's current value for the other. A polymorphicbelongs_totakes its_typeand_id: an_idsent alone keeps the current type, and a_typenaming another type needs its_id.
Undo in the chat still restores the stored row exactly as it was.
Action Required
- A client, prompt or skill that writes raw columns: switch to the names the refusal lists, which are the ones
list_resourcesand the edit form use. - A tool of your own that writes records: write through the fields —
Avo::Ai::FieldScope#fillfor the assistant,FieldSupport.write_record!for the MCP server. See Bring your own tool. - An
update_usingthat isn't safe to run twice now also runs on chat and MCP writes, as it does on the panel's.
-> avo-ai requires ruby_llm ~> 2.1
Breaking Change
avo-ai 4.3.0.beta.4 allowed RubyLLM's 2.0 line (>= 2.0.0.rc4, < 2.1). 4.3.0.beta.5 requires ~> 2.1, the release line whose MCP client MCP connections are built on.
Action Required
None, unless your own Gemfile pins ruby_llm to the 2.0 line: bundle update avo-ai pulls RubyLLM 2.1 along with it, and refuses to resolve while that pin stays. Raise it, or drop it:
# Gemfile
gem "ruby_llm", "~> 2.0.0"
gem "ruby_llm", "~> 2.1"avo 4.3.4 Oct 22, 2026
-> The association show route checks view_{association}? and the related record's show?
Breaking Change
GET /resources/:resource/:id/:related_name/:related_id renders a related record's show page. A has_one panel loads its frame from it, and for every other association it is reachable by URL. It asked no policy at all: anyone who could see the parent record could open any record associated with it, whatever the related resource's show? or policy scope said. This closes GHSA-x65r-p6hf-94hv.
The route now runs the checks the rest of Avo already ran:
- The parent's
view_{association}?, as the association's index already did. - The related record's own
show?, as the resource's show page does. - A
has_manyrow is looked up inside the related resource's policy scope, so a record the scope doesn't hold is not found.
A denied policy method is refused the way any unauthorized page is; a row outside the policy scope behaves like an unknown id.
Action Required
None if your policies already describe what your admins should reach. Three cases change what a user sees:
- A related resource whose
show?is stricter than the parent's. URLs that opened before are now refused. Moving the rule toshow_{association}?only hides the row's button; it does not grant access to the page. - A
has_onefield pointing at a record its resource'sshow?denies. The panel's frame is refused instead of rendering the record. Allowshow?for it, or hide the field withvisible. - A policy scope that excludes records still listed in a panel. Opening such a row now behaves like an unknown id.
avo 4.3.3 Oct 21, 2026
-> Non-standalone actions on an association panel need a selected row
Breaking Change
In a has_many panel, an action that isn't standalone is disabled until you check a row, the same as on the resource's Index view. Before, the panel built its actions with the parent record, the record whose Show page holds the panel, so Avo treated every action as already having a target and let you open and run it with nothing selected (#4904). See Run actions from an association panel.
Action Required
An action that shows on an association panel and reads record in its name or arguments block now gets nil there, as it already did on the main Index view. Guard the block:
# app/avo/actions/release_fish.rb
class Avo::Actions::ReleaseFish < Avo::BaseAction
self.name = -> { "Release #{record.name}" }
self.name = -> { record.present? ? "Release #{record.name}" : "Release fish" }
endThen check your system specs: one that opens an association panel's Actions dropdown and runs a non-standalone action without checking a row now finds that action disabled.
Maintaining Previous Behavior
An action meant to run from the panel with nothing selected is a standalone action. Set standalone to true. It stays enabled, and handle receives an empty query.
# app/avo/actions/export_team_members.rb
class Avo::Actions::ExportTeamMembers < Avo::BaseAction
self.standalone = true
endavo 4.3.2 Oct 14, 2026
-> Fields pick up activerecord.attributes and attributes keys
Breaking Change
A field with no name: and no translation_key: now reads the model's attribute translation — activerecord.attributes.<model>.<attribute> (parent classes included), then attributes.<attribute> — after Avo's own avo.resource_translations.<resource>.fields.<attribute> and avo.field_translations.<attribute> keys, and before humanizing its id. These are the keys Rails already uses for form labels and error messages, so a localized app gets translated admin labels with no change to its Ruby. See Localizing fields.
Avo's own keys still win, and so do name: and translation_key:. A translation_key: that points at a missing key still falls back to the humanized id, not to the model's translation.
Action Required
Grep your locale files for activerecord: and a top-level attributes: key. If you have neither, nothing changes. If you do, every field that was showing a humanized id and matches one of those keys now shows the translation instead. Association fields look up the association name (user), not the foreign key (user_id).
Maintaining Previous Behavior
Keep one label as it was with name: or translation_key: on the field, or turn the lookup off for the whole admin:
# config/initializers/avo.rb
config.model_attribute_translations = falseavo-ai 4.3.0.beta.3 Oct 14, 2026
-> The retracted chat button renders _pit_core_icon
Breaking Change
Hiding the toolbar collapses the chat dock to a single round button, and that button now renders avo/ai/_pit_core_icon instead of avo/ai/_avocado_icon. An app that replaced the assistant's icon with its own _avocado_icon partial keeps its icon everywhere else, but this button goes back to Avo's own mark. See Replace the assistant's icon.
Action Required
None, unless you override _avocado_icon. If you do, add a _pit_core_icon partial next to it. Pass classes through and add no size utility of your own: the stylesheet sizes this mark as a share of the button, and a size class in your markup outranks that rule.
<%# app/views/avo/ai/_pit_core_icon.html.erb %>
<%= svg "tabler/outline/robot", class: local_assigns[:classes] %>avo 4.3.1 Oct 14, 2026
-> Selected rows come back when you go back to an index
Breaking Change
Going back to an index page now puts back the rows you had selected and where you had scrolled. That happens with the browser's Back button and with the Go back link on a record page. Reaching the index any other way, or reloading it, starts with nothing selected, and running an action forgets the selection. Before, Go back opened the index at the top with nothing selected. See Coming back to the index.
Action Required
None in production. Check your system specs: one that selects rows, opens a record, clicks Go back and expects nothing to be selected now finds those rows still checked.
-> Badges follow except_on and show_on: :all on forms
Breaking Change
A badge can now render a select on New and Edit. It still stays off forms by default. Before, a badge was forced off forms after its visibility options ran, so except_on: :index or show_on: :all left it display-only there. Now a badge follows those options like every other field, and both put the select on forms. See Editing the value.
Action Required
Search your resources for badges declared with except_on or show_on: :all. Each one now shows a select on New and Edit. The select keeps the record's current value, so saving a form doesn't change it.
Maintaining Previous Behavior
Add :forms to except_on to keep the badge display-only.
# app/avo/resources/project.rb
field :status, as: :badge, except_on: :index, options: {success: "done"}
field :status, as: :badge, except_on: [:index, :forms], options: {success: "done"} -> ?set_locale= is stored only for an authenticated request
Breaking Change
Avo writes ?set_locale= into Avo.configuration.locale only after your authenticate_with block lets the request through. Before, the write happened in an around_action that ran first, so a request the block rejected still switched the language for every user until the server restarted. A controller that skips Avo's authentication altogether can no longer store it either (#4893).
Action Required
None in production. If a link or script outside the admin reached an Avo URL with ?set_locale= while signed out to set the language, it no longer has any effect — set config.locale for everyone, or let each user pick their own with the locale selector.
WARNING
On earlier versions anyone who could reach an Avo URL could change the admin's language for every user, signed in or not. Upgrade rather than working around it.
-> ?set_locale= deprecated, and unknown locales in both locale params are ignored
Breaking Change
?set_locale= logs a deprecation warning and will be removed in a future version — it has always changed Avo.configuration.locale for the whole process, so it switches the language for every user until the server restarts. Both ?set_locale= and ?force_locale= now ignore a value missing from I18n.available_locales and move on to the next source. Before, an unknown set_locale was stored globally and made I18n.with_locale raise I18n::InvalidLocale on every later request. See Setting the locale.
Action Required
A request carrying a locale your app doesn't list now keeps the configured locale instead of switching, so a spec that asserted a bogus ?force_locale= renders in that locale needs updating. Everything else keeps working; the warning in the logs is the only new signal.
Steps to Update
Grep your links, bookmarks and specs for set_locale. To change the language for everyone, set it in the initializer; to let each user choose, use the locale selector, which stores the choice per browser.
# config/initializers/avo.rb
config.locale = :"pt-BR"avo-audit_logging 4.3.0 Oct 6, 2026
-> Activities record where they came from — run the installer again
What changed
An activity now carries an origin: origin, a short name for whatever acted, and the optional origin_record pointing at the thing itself. A change made in the panel has none. A change made through an MCP connection, an API token or an Avo AI chat is marked mcp_connection, api_token (or api) and ai_chat, and links back to it — a record's timeline reads Ada Lovelace through MCP connection. Until this version an MCP client's changes were deliberately indistinguishable from the admin's own.
The two columns are a new migration. A first install writes one migration with them included; an app that installed an earlier version has the table without them.
Action Required
bin/rails generate avo:audit_logging upgrade
bin/rails db:migrateupgrade reads your db/migrate and writes only what is missing — it never touches a migration you have already run. Running install again does the same thing.
Until you migrate, nothing breaks: activities keep being recorded, just without an origin, and the Audit log table on a connection's, token's or chat's page is not offered rather than shown empty.
If you have written Avo::AuditLogging::Activity yourself — an importer, a job — you can mark those too; see Mark your own origins.
-> The audit log is off limits to MCP clients, API tokens and the assistant
What changed
Avo::AuditLogging::Activity is no longer offered as a resource through avo-mcp_server's tools, avo-api's routes and entitlements grid, or the Avo AI assistant, whatever your policies say. An agent or a credential that could delete the record of what it just did would make the log worthless.
Action Required
Nothing, unless a client of yours was reading /api/resources/avo_activities or asking the assistant about activities — those now answer as if the resource did not exist. Read the audit log in the panel instead.
avo 4.3.0 Oct 6, 2026
-> avo-api token scopes are now called entitlements
Breaking Change
"Scope" already means a policy scope in Avo and an ActiveRecord scope in every app running it, so what an API token is allowed to reach is called an entitlement from 4.3.0 on. 4.2.0 is the only version that shipped it under the old name.
The rename reaches the database column, the panel, the 403 body, and the token policy method:
| 4.2.0 | 4.3.0 |
|---|---|
avo_api_tokens.scopes column | avo_api_tokens.entitlements |
token.scopes | token.entitlements |
edit_scopes? on your token policy | edit_entitlements? |
"reason": "token_scope" in a 403 | "reason": "token_entitlement" |
scopes_grid in a form post | entitlements_grid |
| The Scopes panel | The Entitlements panel |
Action Required
Re-run the tokens generator and migrate. It writes RenameAvoApiTokenScopes and skips the create migration you already have:
rails generate avo_api:tokens
rails db:migrateThen grep your app for the left-hand column above. Two are worth checking before the others:
edit_scopes?on your token policy. Nothing asks that method any more, so the question falls back to the permissive default and every panel user may edit entitlements. Rename it.- A client branching on
"reason": "token_scope". It now reads"token_entitlement"and a branch matching the old string falls through.
Nothing grants or revokes anything on its own: the migration renames a column and every stored grant is carried over untouched.
avo-mcp_server 4.3.0.beta.1 Oct 6, 2026
a shipped count_records tool
-> An app's own count_records tool is no longer served
What changed
The server ships a tenth tool, count_records, which answers "how many" with a number and no records. See Tools.
Action Required
Nothing, unless the app registers its own tool named count_records through extra_tools, including a subclass of Avo AI's count tool. The shipped tool now takes that name and the app's is dropped with a log line. Give the app's tool another name to keep serving it.
avo 4.2.11 Sep 29, 2026
forms ask before you leave with unsaved changes
-> warn_on_unsaved_changes is on by default
What changed
Avo now asks for confirmation before a user leaves a New or Edit form with unsaved changes. Saving never asks, and a change that was undone does not count. See config.warn_on_unsaved_changes.
Action Required
None in production. Check your system specs: one that changes a form and then navigates away without saving now meets the confirmation dialog. There are two ways to handle it:
- Accept the dialog in those specs with
accept_custom_alertfrom the testing helpers. - Turn the warning off in the test environment only, and keep it on everywhere else.
# config/initializers/avo.rb
Avo.configure do |config|
# Off in the test environment, on everywhere else.
config.warn_on_unsaved_changes = !Rails.env.test?
endINFO
With the warning off in tests, your specs no longer go through what your users see when they leave a form.
Maintaining Previous Behavior
Turn it off in every environment.
# config/initializers/avo.rb
Avo.configure do |config|
config.warn_on_unsaved_changes = false
endavo 4.2.10 Sep 28, 2026
Built-in neutral themes now share one lightness curve
-> Built-in neutral presets keep their tint but use a consistent surface hierarchy
What Changed
Slate, Stone, Gray, Zinc, Neutral, Taupe, Mauve, Mist, and Olive now use Brand's lightness at every shade while preserving their original hue and chroma. Dark primary surfaces are also slightly softer. Navbar text continues to use each named preset's original 300 shade for contrast.
Custom palettes supplied through neutral_colors are unchanged.
Action Required
None, unless custom CSS depends on the previous built-in shade values. Review overrides that use --color-avo-neutral-* while a named preset is active, and use the public appearance variables when a surface needs a fixed color.
avo-authorization 4.2.1 Sep 27, 2026
Field authorization
-> Nothing to add to your policies
What changed
A Pundit policy can now name which of a resource's fields a user may reach, with whitelisted_fields and blacklisted_fields, and every Avo surface honors the answer for reads and writes. See Field authorization.
Action Required
None. Field lists are opt-in: a policy that declares neither restricts nothing, whether explicit_authorization is on or off. Behavior changes only once a policy declares a list.
Release avo-authorization 4.2.1 together with, or before, avo-ai 4.2.0.beta.6 and avo-mcp_server 4.2.0.beta.9. Both call code that only 4.2.1 ships.
avo-api 4.2.7 Sep 27, 2026
visible:` hides a field from the API too
-> A field whose visible: proc answers false is no longer serialized
What changed
The API used to check only a field's show_on / hide_on options, so a field a visible: proc hid from a user in the panel was still in that user's API payload. It is now left out, the same as in the panel.
A failed create, update or delete no longer names a field a policy withholds from the token's owner in errors. The failure is reported on base instead.
Action Required
Nothing, unless an API client reads a field that a visible: proc hides from the token's owner. Make the proc answer true for that user, or the field stays out of the payload.
avo-ai 4.2.0.beta.6 Sep 27, 2026
the chat reads through the resource's fields
-> A column no field declares is no longer reachable from the chat
What changed
The assistant used to read, filter, sort, group and search any column on the model, except password- and token-shaped ones. It now goes through the resource the way the panel does. A column is reachable only when a field the user may reach claims it, and associations are traversed only through an association field. A column no field declares is not returned, filtered, sorted, grouped, searched, written, imported, or named in a schema or an inspector report.
Three things the panel exposes without a field still count:
discreet_information: the timestamps and id a resource lists there are readable.created_at: it stays a sort key whenever the column exists.extra_params: the columns a resource opens there are writable.
A model with no Avo resource is never queried or described, and an association check through one is refused.
Action Required
Declare a field for every column the chat should work with. A field only one role should see still takes a visible: proc or, with avo-authorization, a policy field list. See How the assistant works.
avo-mcp_server 4.2.0.beta.9 Sep 27, 2026
sorting and errors follow the resource's fields
-> Clients sort by field columns, and errors name only what the admin may see
What changed
- Sorting:
list_recordssorts only by a column behind a field the admin may reach, or bycreated_at. Any othersort_byis refused, with the sortable columns listed. - Validation errors: failed writes name only attributes within the admin's reach. This covers the messages,
requiredAttributes,optionalAttributesandmissingRequiredAttributes. The server log records which attributes were dropped. update_record: itschangedlist names only readable columns.
Action Required
Nothing, unless a client sorts by a column with no field. Declare a field for it. See The MCP server.
-> An action a client runs sees view as show or index, never new
Breaking Change
An action run through run_action used to be built on view: :new. It is now built on the view the panel would have started it from: show when the run targets one record, index for a bulk run or one that takes no records. That is the view its authorize block, its fields and handle read. A bulk run also hands the action no current record, as the panel does. Nothing raises: an action that reads view simply answers differently. See An action runs on the view it would have been started from.
Action Required
None for an action that ignores view. Re-read the ones that read it:
authorizereadingview.-> { view.index? }refused every client run before and now allows a bulk or standalone one.-> { view.new? }allowed every client run and now allows none.fieldsorhandlebranching onview.new?took that branch on every client run and now takes it on none. Branch onrecord, onquery.size, or onview.index?instead.
avo-ai 4.2.0.beta.5 Sep 27, 2026
the composer limits what it uploads
-> Files over 25 MB, and types the assistant can't use, are refused
What changed
The chat composer used to upload any file. It now takes images (PNG, JPEG, GIF, WebP), PDFs and text files — markdown, plain text, CSV, TSV, JSON — up to 25 MB, and refuses anything else as it is added, before it uploads, with a line under the draft saying why. A message sent without the composer is held to the same rule: a file it would have refused is left off the message.
Action Required
Nothing, unless people upload other files — say, Word documents or videos to attach to records. Allow them under config.ai.uploads; see What the composer accepts.
config.ai.uploads = {accept: ["*/*"], max_size: 100.megabytes} -> Protect Rails' direct-upload endpoint
What changed
Nothing in Avo — this was always true. The composer uploads through POST /rails/active_storage/direct_uploads, which Rails ships without authentication, and the new limits don't cover a request that goes straight to it.
Action Required
Add your app's authentication and a size check to that endpoint: Protect the direct-upload endpoint.
records are named by to_param
-> Records are named by to_param, not their primary key
What changed
The assistant names every record the way Avo's own URLs do: by to_param. On a resource that hides its primary keys behind friendly_id, a hashid gem, or its own to_param, the slug now appears in the system prompt, in record labels (Hello world (#hello-world)), in chip and card links, and in the reference the tools hand the model. The tools look an id up through the resource's find_record_method, and still accept a primary key. Their id parameters are strings.
Resources that keep Rails' default to_param see no difference.
Action Required
Nothing, unless you replaced part of the assistant with your own copy. Check each of these:
- An ejected
attached_context.txt.erb. Name records byattached_record[:record_param]and each selected record'srecord_param, notrecord_id, or your prompt keeps the primary key. See Change how it reads. - An ejected write tool (
update_record,delete_record,update_records,run_action,active_storage_attachment). The model now passes a slug. Look it up withfind_authorized_record_by_param, and declare theidparameter as a string. Diff your copy against the gem's. - A tool of your own that returns a
reference. Build it fromrecord.to_param. A reference built fromrecord.idon a slugged resource no longer renders as a chip. See Bring your own tool.
-> An action the assistant runs sees view as show or index, never new
Breaking Change
An action the assistant runs used to be built on view: :new. It is now built on the view the panel would have started it from: show when the run targets one record, index for a bulk run or one that takes no records. That is the view its authorize block, its fields and handle read. Nothing raises: an action that reads view simply answers differently. See Your actions, from the chat.
Action Required
None for an action that ignores view. Re-read the ones that read it:
authorizereadingview.-> { view.index? }refused every chat run before and now allows a bulk or standalone one.-> { view.new? }allowed every chat run and now allows none.fieldsorhandlebranching onview.new?took that branch on every chat run and now takes it on none. Branch onrecord, onquery.size, or onview.index?instead.
avo 4.2.8 Sep 26, 2026
-> view inside an action is the page it was started from, never new
Breaking Change
Every block of an action (visible, authorize, the modal's texts, fields, and handle) used to see view as new while the modal rendered and while the action ran. It is now the view the action was started from: index, show, or edit. It is never new, and it stays the same from the modal through handle (#4825). See Control visibility and authorization.
Action Required
Search your actions for view and drop any branch that expects new:
# app/avo/actions/toggle_inactive.rb
def handle(query:, fields:, **)
reload_records if view.new? # never true now
reload_records if view.index? # the action was started from the Index page
endNone if your actions don't read view.
-> Every field an action declares renders in its modal
Breaking Change
An action's fields used to be filtered by their show_on / hide_on marks against the new view, so a field those marks kept off forms never reached the modal. Those marks describe resource views, so all of an action's fields render now (#4825). A field with no form component, such as badge, renders read-only, as it does on the Show view. See Collect input with fields.
Action Required
Look at the actions whose fields carry those marks: the ones that were dropped now show up in the modal. Take a field you don't want there out of the action's fields, or hide it with a visible block.
# app/avo/actions/toggle_inactive.rb
def fields
field :status, as: :badge, hide_on: :forms # renders read-only in the modal now
field :notify_user, as: :boolean
field :message, as: :textarea
endavo-ai 4.2.0.beta.4 Sep 26, 2026
-> Batch updates are undone as one change: run the installer again
What changed
The assistant can now change many records in one confirmation with the new update_records tool, and undo the whole batch from one card. Undo knows which records belong to a batch through a new column, avo_ai_write_logs.pending_write_id. A first install gets it in the create migration; an app that installed an earlier version does not have it.
Action Required
bin/rails generate avo:ai install
bin/rails db:migrateThe installer reads your db/migrate and writes only the migrations you are missing, so running it again is safe.
Until you migrate nothing breaks: batch updates still work, but the history lists and undoes a batch record by record instead of as one entry.
-> A new write tool arrives switched on: update_records
What changed
update_records is a new shipped tool, and config.ai.excluded_tools is a list of what to remove, so every app gets it unless it names it. It writes nothing without the user's click on a confirmation card, under the same policies and field rules as update_record.
Action Required
Nothing, unless your initializer excludes update_record to keep the assistant from editing records. Exclude the new tool too, or it can still update them in batches:
config.ai.excluded_tools = [:update_record, :update_records] A batch is capped at 50 records. To change that, set config.ai.max_update_records to a number from 1 to 500; anything else raises at boot.
-> Typing a confirmation with several cards on screen confirms nothing
What changed
A typed "do it" used to confirm the last card when one reply had put several on screen. It now confirms none of them and goes to the assistant as an ordinary message, since it cannot tell which card you meant. With one card waiting, a typed confirmation now also still reaches it after the assistant has only reminded you to click.
Action Required
Nothing. Click Confirm on the card you want.
avo 4.2.0 Sep 1, 2026
-> super in a setup_authentication override now accepts a valid API token
Breaking Change
avo-api ships API tokens. The default setup_authentication on BaseResourcesController used to raise, closing the API until you replaced it. It now accepts a request carrying a valid API token, authenticated as that token's owner, and rejects everything else.
That changes what super means inside an existing override.
Action Required
Grep app/controllers/avo/api/ for setup_authentication and check every override that calls super. An override that never calls it is unaffected.
Drop the super call to opt out of tokens entirely and leave your own scheme in sole charge:
# app/controllers/avo/api/resources/v1/base_resources_controller.rb
def setup_authentication
super
authenticate_with_my_scheme!
endKeep it to accept tokens alongside your scheme — but note a token then reaches everything its owner reaches, since a token acts as its owner.
-> avo-api now ships a migration
Breaking Change
The tokens table is new, and the token resource appears in the Avo sidebar by default as soon as the gem is upgraded — before the table exists.
Action Required
You are already running the API, so your app has the controllers and needs only the table:
rails generate avo_api:tokens
rails db:migrateUntil you do, opening the API tokens screen, or sending any request with an Authorization: Bearer … header, queries a table that isn't there and errors. Running the generator twice is a no-op rather than an error.
Reach for rails generate avo_api:install only on a first install — it invokes avo_api:generate as well, which regenerates every resource controller and asks to overwrite the ones you have customized. avo_api:tokens is the upgrade path and leaves them alone.
If you don't want tokens at all, replace the authentication hook so the token lookup never runs.
-> A denied policy method answers 403 JSON instead of a 302 redirect
Breaking Change
Policy methods (index?, update?, …) returning false raise Avo::NotAuthorizedError, which Avo handled with a flash message and a redirect to your root URL — right for the HTML panel, unreadable for a JSON client. The API now renders JSON instead:
{ "error": "Forbidden", "reason": "policy" }A token scope refusal is also a 403, and reason is what tells the two apart.
Action required: None for most apps — this replaces a redirect no API client could act on. Two cases to check:
- A client that read the
302as "denied" now gets a403. One that followed the redirect and parsed the resulting HTML now gets JSON. - A
rescue_from Avo::NotAuthorizedErroryou added to your ownBaseResourcesControllerstill wins over the gem's, so your response shape is unchanged. Delete it to adopt the gem's, or keep it.
-> Tokens can now be scoped, and are unrestricted until they are
Breaking Change
None — this is additive. Existing tokens carry no grants, and a token with no grants reaches everything its owner can, exactly as before.
Action required: None. Worth knowing before you use it: granting a token anything restricts it to what you granted, including against resources you deploy later, and removing the last grant leaves a token that refuses everything rather than one that is unrestricted again. Make unrestricted in the token's panel is the way back.
Editing grants is gated by a policy method, which — like every other token policy method — answers yes when no authorization client is configured.
Renamed in 4.3.0
This shipped as Scopes, with an edit_scopes? policy method. Both are called entitlements from 4.3.0 on — see Upgrade to 4.3.0.
avo 4.1.15 Aug 20, 2026
-> container_width values renamed to Tailwind's scale
Deprecation
config.container_width (and self.container_width on Avo Forms pages and forms) used the words :large and :small, while every other size option in Avo uses Tailwind's scale — size: :sm, width: :xl, size: :md. The widths now use that scale too:
| Before | After |
|---|---|
:large | :lg |
:small | :md |
:full | :full (unchanged) |
:small became :md rather than :sm deliberately: it keeps every existing screen at the width it already had, and frees the narrower names. A new :sm (720px, clamping from 768px) ships in the same release for single-column screens — nothing gets it automatically, it is opt-in.
Action Required
None immediately. The old names are still accepted and mapped for you — they log a deprecation warning through Avo.logger and will be removed in Avo 5. Update them when convenient:
# config/initializers/avo.rb
config.container_width = :small
config.container_width = :md
config.container_width = { index: :large }
config.container_width = { index: :lg } # app/avo/pages/settings.rb
self.container_width = :large
self.container_width = :lgIf you style Avo's container in your own CSS, this part is not aliased — the emitted class names changed and your overrides need renaming:
| Before | After |
|---|---|
.container-large | .container-lg |
.container-small | .container-md |
.container-full-width | .container-full |
avo 4.1.11 Aug 17, 2026
-> stacked: false now opts a field out of sidebar and width stacking
Breaking Change
A field declared stacked: false used to be ignored in the places Avo stacks on its own — inside a sidebar, in the record preview, and on any field with a width below 100. The field's own declaration now wins over those defaults, so such a field renders inline. See stacked.
Action Required
If you never pass stacked: false, nothing changes — the defaults are untouched.
If you do, the fields that carry it move from stacked to inline. Drop the option to keep them stacked.
# app/avo/resources/user.rb
field :name, as: :text, stacked: false
field :name, as: :textavo-calendar 4.0.3 Aug 16, 2026
-> +N more no longer opens the week view
In the month view, the +N more toggle under a crowded day now expands that week row in place to reveal every event, with a Show less control to fit it back. It previously navigated to that day's week view. See Read a crowded day.
Action required: None — the behavior change is visual only, and the week view stays one click away in the header's Month/Week toggle.
-> Week view becomes a scrollable time grid
Hour-based events now stretch to their duration — overlapping events share the day column side by side — instead of rendering as fixed chips on their start hour, and the grid scrolls inside the viewport, opening at 07:00 under a pinned day-header row. See Month and week views.
Action required: None — the change is visual only. An event without an ends_at renders as a one-hour block.
avo 4.1.10 Aug 15, 2026
-> code and easy_mde collapse their content on Show
The code and easy_mde fields now render on the Show view through the same collapsible wrapper the rich text fields use: the value is clipped to a short, faded preview with a More content link that expands it and a Less content link that collapses it back. They previously rendered the full value.
Action required: None unless you want the full value visible on load. Pass always_show: true on the field to skip the collapsing:
# app/avo/resources/product.rb
field :custom_css, as: :code, always_show: trueSee WYSIWYG & Markdown editors for the behavior every editor field now shares.
-> Editors render in a bounded, resizable viewport on forms
On Edit and New, every editor field (trix, rhino, lexxy, markdown, easy_mde, tip_tap and code) now renders inside a viewport that opens 20rem tall, scrolls its overflow, and can be dragged taller or shorter by its bottom-end handle. Editors previously grew with their content. Each height is stored in the browser's local storage and restored the next time that form renders.
Action required: None. If the default size doesn't suit your content, set the heights globally in a stylesheet you register with Avo:
/* app/assets/stylesheets/avo_custom.css */
:root {
--avo-resizable-editor-default-height: 30rem;
--avo-resizable-editor-min-height: 15rem;
}Note that the code field's height option now only applies on the Show view — on forms the viewport's height wins.
avo 4.1.7 Aug 13, 2026
-> config.visible now controls Media Library access, not just the menu item
Breaking Change
Avo::MediaLibrary.configuration.visible used to hide the sidebar item and the rich text editor button while leaving every Media Library route reachable by URL. It is now enforced on the routes as well: a user the block returns false for gets a 404 from /media-library and /attach-media.
This closes GHSA-cff8-4h3c-9r4q, where any authenticated Avo user could browse, rename and permanently delete every Active Storage blob in the application.
Action Required
If you set config.visible, check that it returns true for everyone who is meant to use the Media Library, including through the rich text editors. Anyone it rejects now loses access entirely rather than only losing the menu item.
If you don't set it, nothing changes on upgrade — visible defaults to true. That also means the Media Library stays available to every user who can sign in to Avo, so set it if that isn't what you want:
# config/initializers/avo.rb
if defined?(Avo::MediaLibrary)
Avo::MediaLibrary.configure do |config|
config.visible = -> { Avo::Current.user.is_developer? }
end
endMigrating to TailwindCSS 4? See the TailwindCSS 4 Migration Guide.
avo-dashboards + avo-scopes 4.1.2 Aug 11, 2026
-> Cards, dashboards, and scopes resolve their labels from locale keys first
Breaking Change
Cards, dashboards, and scopes now derive an i18n key from their class path and look it up before falling back to the class attribute, the same way resources and actions already did:
| Class | Key Avo looks up | Options under it |
|---|---|---|
| Card | avo.card_translations.<class_path> | label, description, discreet_description |
| Dashboard | avo.dashboard_translations.<class_path> | name, description |
| Scope | avo.scope_translations.<class_path> | name, description |
If your locale files already define one of those keys, it now wins over self.label, self.name, or self.description — including when the attribute is a lambda. The lambda isn't called at all, so anything it computed is dropped, with nothing in the logs.
A lambda that reads the same key stops running
This bites hardest when the attribute builds a string around the very key Avo now derives:
# app/avo/cards/upcoming_metric.rb
self.description = -> {
I18n.t("avo.card_translations.upcoming_metric.description") + " (#{timeframe})"
}Avo finds avo.card_translations.upcoming_metric.description, renders it verbatim, and the (#{timeframe}) suffix disappears.
Action Required
Grep your locale files for card_translations, dashboard_translations, and scope_translations. Avo ships nothing under those roots, so if you weren't using them there's nothing to do — a missing key still falls through to your class attribute exactly as before.
If you were using them for your own I18n.t lookups, pick one of the three ways out below.
Steps to Update
1. Move your own key out of the derived namespace. The cleanest fix when the class attribute has to keep computing something:
# config/locales/avo.en.yml
en:
avo:
card_translations:
upcoming_metric:
description: Forecasted revenue based on current bookings
description_text: Forecasted revenue based on current bookings# app/avo/cards/upcoming_metric.rb
self.description = -> {
I18n.t("avo.card_translations.upcoming_metric.description") + " (#{timeframe})"
I18n.t("avo.card_translations.upcoming_metric.description_text") + " (#{timeframe})"
}2. Point Avo's lookup somewhere else. self.translation_key moves the derived key and leaves your own I18n.t call untouched:
# app/avo/cards/upcoming_metric.rb
class Avo::Cards::UpcomingMetric < Avo::Cards::MetricCard
self.translation_key = "avo.card_translations.upcoming_metric_chrome"
end3. Override at the registration site. A value passed to card beats the locale key, so one registration can keep a computed description while the others stay translated:
# app/avo/dashboards/dashy.rb
card Avo::Cards::UpcomingMetric, description: -> { "#{I18n.t("...")} (#{timeframe})" }Actions have no registration site, so only the first two apply there — see Upgrade to 4.0.17.
See the key map for every key Avo derives.
avo 4.1.4 Aug 10, 2026
-> The back to top pill is enabled by default
Breaking Change
The "Back to top" pill used to be off by default and only revealed itself when the user scrolled back up. It's now on by default and its visibility depends only on how far down the page is scrolled, not on the scroll direction. The threshold default moved from 64 to 400 pixels so the pill stays out of the way on short scrolls.
Action required: None, unless you don't want the pill in your app. Every app that doesn't configure back_to_top gets it after this upgrade (#4705).
Maintaining Previous Behavior
Turn it off from the initializer. Keys you leave out fall back to the defaults, so enabled is enough:
# config/initializers/avo.rb
Avo.configure do |config|
config.back_to_top = {enabled: false}
endThe direction-aware reveal is gone for good — threshold is now the only thing that decides when the pill shows up.
-> use_browser_timezone now defaults to true
Breaking Change
Server-side dates and times now render in each visitor's own time zone by default instead of the app's Time.zone. Avo detects the browser's zone via a cookie; the first page a browser loads soft-reloads once through Turbo and shows a one-time alert that times are now displayed in the visitor's zone.
Action required: None if per-visitor times are what you want — most apps do. Displayed values only change for users whose browser zone differs from the app zone; nothing about stored data changes.
Since 4.1.8 the option defaults to false in the test environment, so browser/system tests are unaffected — the first-load soft reload would otherwise race them. On 4.1.4 through 4.1.7 it is on in test as well; set config.use_browser_timezone = !Rails.env.test? if you stay on one of those and your system specs started flaking.
Maintaining Previous Behavior
Pin every visitor to the app's configured zone:
# config/initializers/avo.rb
Avo.configure do |config|
config.use_browser_timezone = false
endavo-dashboards 4.0.9 Jul 28, 2026
-> The per-card refresh control is opt-in
Breaking Change
avo-dashboards 4.0.8 rendered a refresh control on every card, with no way to remove it — including on html and static partial cards, where refreshing re-renders identical content. The control is now off by default and turns on per card with refresh_button, the same name and the same default as the dashboard-wide control that shipped alongside it.
Action required: None unless you were on 4.0.8 and want the control kept on a card.
Maintaining Previous Behavior
Turn it back on for each card that should keep it:
# app/avo/cards/users_metric.rb
class Avo::Cards::UsersMetric < Avo::Cards::MetricCard
self.id = 'users_metric'
self.refresh_button = true
endSetting it on a dashboard does not turn it on for that dashboard's cards — the two controls are independent opt-ins under the same name.
avo 4.0.20 Jul 28, 2026
-> Sidebar labels truncate instead of wrapping
Breaking Change
The sidebar is now resizable, and as part of that change sidebar link, section, and group labels render on a single line with an ellipsis when they overflow, instead of wrapping onto multiple lines. The full label shows in a tooltip on hover.
Action required: None for most apps. If your navigation relied on long labels wrapping, either shorten the labels or point users at the drag handle to widen the sidebar.
Hosts with an accessibility conformance obligation can disable the drag handle. The off-switch was config.sidebar_resizable on 4.0.20 and 4.0.21; from 4.0.22 it moved into the grouped config as sidebar[:resizable] and the old name no longer works.
-> .container-small is now max-width-based
Breaking Change
.container-small (the wrapper around show and edit pages) used a fixed width; it now fills its container up to a max-width so content adapts when the sidebar is wide. If you override .container-small's width from your own stylesheets, the new Avo-owned max-width now clamps it — override max-width instead.
Action required: None unless you override .container-small.
avo 4.0.18 Jul 23, 2026
-> Resource and field translations are used verbatim
Breaking Change
Avo used to humanize every resource and field label it resolved from your locale files. That silently overrode the casing you wrote: 'Payment Intent ID' rendered as Payment intent id, and 'API Products' rendered as Api products.
Avo now renders a resolved translation exactly as written and humanizes only the name it generates for you when no translation is found.
Lowercase translations now render lowercase
If your locale entries are lowercase, they used to appear capitalized. They will now appear as written.
Action Required
Review your locale files. If every resource_translations and field_translations entry is already written the way you want it on screen, there's nothing to do.
Avo's own interface strings are unaffected — the "New", "Edit", and "View" labels still render as before.
Steps to Update
Grep your locale files for resource_translations and field_translations, and capitalize any entry you want capitalized on screen:
# config/locales/avo.pt-BR.yml
pt-BR:
avo:
resource_translations:
user:
zero: 'usuários'
one: 'usuário'
other: 'usuários'
zero: 'Usuários'
one: 'Usuário'
other: 'Usuários'
field_translations:
file:
zero: 'arquivos'
one: 'arquivo'
other: 'arquivos'
zero: 'Arquivos'
one: 'Arquivo'
other: 'Arquivos'Nothing else changes: resources and fields without a translation still fall back to the humanized class or attribute name.
See Localization (i18n) for the full picture.
avo 4.0.17 Jul 23, 2026
-> Action labels resolve from avo.action_translations first
Breaking Change
Actions now look up avo.action_translations.<class_path>.{name,message,confirm_button_label,cancel_button_label,description} before falling back to self.name, self.message, self.confirm_button_label, self.cancel_button_label, and self.description, mirroring how resources already resolved their names.
If your locale files already define one of those keys, it now wins over the class attribute — including when the attribute is a lambda, which is then never called, so anything it computed is dropped with nothing in the logs.
Action Required
Grep your locale files for action_translations. Avo ships nothing under that root, so if you weren't using it nothing changes: an action with no key still renders its class attribute, and an action with neither still renders its humanized class name.
Steps to Update
If a key you own collides with an action's derived key, either rename your key, or point Avo's lookup elsewhere with self.translation_key:
# app/avo/actions/toggle_inactive.rb
class Avo::Actions::ToggleInactive < Avo::BaseAction
self.translation_key = "avo.action_translations.toggle_inactive_chrome"
endavo-dashboards and avo-scopes got the same treatment in their 4.1.2 releases — see that section.
See Localizing actions for the full picture.