Review: Ash run-time architecture
Review: ash-runtime (03-ash-runtime-internals.md)
Review of: Ash run-time architecture.
VERDICT: ACCEPT-WITH-FIXES
Reviewer: independent fact-check, 2026-10-01. Ground truth: scratch/ash-src/ (ash 3.33.11, ash_sql 0.7.6, ash_postgres 2.13.1, splode 0.3.2; versions confirmed). All paths below are relative to scratch/ash-src/ unless stated. “Doc L” = line in the research document.
Why REJECT
The document covers a lot of ground, and about 60% of what I sampled is right. But the parts mesh will copy most directly are wrong in ways that would cost real redesign later:
- Read lifecycle. The document says reads have no transaction. In fact a read opens one when
transaction? true. It also says authorization comes afterbefore_transaction, when in code it comes before. Finally, it describesdata_layer_query/5, which is not the main read path. - Generic actions. The document says preparations exist only on reads. Generic actions run preparations and validations at input-build time.
- Atomic update path. A single-record atomic update is routed through
Ash.Actions.Update.Bulk.run. The changes run twice:change/3while the changeset is built, thenatomic/3. Authorization becomes an error expression inside the statement. - Bulk strategies. The semantics, the default and the “what is lost” table were copied from
Ash.update_many, which is a different API. They were then applied tobulk_update/bulk_destroy. - Data layer contract. The source declares 46 callbacks, of which 44 are optional; the document says 40, all optional. Mnesia is described as a wrapper around ETS. It is a standalone data layer and the only built-in one that supports transactions.
- Contract table. The
Ash.Resource.Change,Ash.Authorizer,Ash.Type,Ash.Resource.Validationand manual-action rows contradict their own@optional_callbacks. - Pain points. Criterion 12 has no community evidence. I add 10 items below.
A targeted revision is enough; a rewrite is not needed. Fix every row marked “high” and rebuild sections 1.2–1.6, 2.3, 3.1, 3.4 and 11 from the corrected material below.
1. Error table
| Doc L | Claim | What the source says | Sev |
|---|---|---|---|
| 9-12 | Version lines mix.exs:3 / :2 |
@version is at ash/mix.exs:13, ash_sql/mix.exs:13, ash_postgres/mix.exs:12, splode/mix.exs:8. The values are correct. |
low |
| 20, 57-67 | Onion nesting omits a phase | After the after_action hooks, and still inside the transaction, run_authorize_results/2 runs: ash/lib/ash/changeset/changeset.ex:5387-5395. This is where create filter policies run (ash/lib/ash/can.ex:1618-1700). Add it between after_action and commit. |
high |
| 22 | “Preparations are a read-side-only concept” | Generic actions run global preparations, then action preparations, then global validations, inside Ash.ActionInput.for_action/4: ash/lib/ash/action_input.ex:253-271, :1328-1361. |
high |
| 22, 156-163 | for_read = cast, defaults, require, preparations, action filters |
It also runs global read validations after the preparations, and before_action? validations become query before_action hooks: ash/lib/ash/query/query.ex:1122-1167. |
medium |
| 23, 171, 185 | Read authorization happens “inside data_layer_query/6”; hook order “before_transaction → (authorizations, filters) → before_action” |
The main path is do_read/5 (ash/lib/ash/actions/read/read.ex:569-850); data_layer_query/5 (:896) is only used with data_layer_query?: true (:420-427). In do_read, authorize_query (:579) runs before run_before_transaction_hooks (:609). |
high |
| 152, 185 | “Read has no data-layer transaction of its own” | maybe_in_transaction/3 opens Ash.DataLayer.transaction when query.action.transaction? is true: ash/lib/ash/actions/read/read.ex:1650-1676. The read action option transaction? defaults to false (ash/lib/ash/resource/actions/read.ex:27). On reads, after_transaction hooks run inside that transaction (read.ex:821 is inside the func passed at :653). |
high |
| 24, 217 | require_atomic? default true “applied to primary actions by a transformer” |
Actions generated by defaults [...] get :default_actions_require_atomic?, which defaults to false: ash/lib/ash/resource/transformers/set_primary_actions.ex:18-22, :155. The installer sets it to true (ash/lib/mix/tasks/install/ash.install.ex:188). Only explicitly declared actions default to true. |
medium |
| 217 | “A compile-time verifier rejects update/destroy actions…” | The verifier returns {:warn, ...}, so these are compile warnings, not errors: ash/lib/ash/resource/verifiers/verify_actions_atomic.ex:147 (the end of verify/1, {:warn, Enum.map(warnings, &Exception.message/1)}). The hard failure happens at run time as Ash.Error.Framework.MustBeAtomic: ash/lib/ash/actions/update/update.ex:257-269. |
medium |
| 25, 221-229 | Bulk strategies for bulk_update/bulk_destroy cited to update_many.ex:8-31; :atomic_batches “Default”; :atomic = “one SQL MERGE” |
update_many.ex is Ash.update_many (per-record inputs). For Ash.bulk_update, the strategy default is [:atomic] (ash/lib/ash.ex:574-578); for bulk_destroy it is :atomic (ash.ex:634-639). :atomic means one update_query over the query (UPDATE … WHERE filter). :atomic_batches means streaming records, then one atomic UPDATE per batch with id IN (...). :stream means a changeset per record. Source: ash/documentation/topics/actions/update-actions.md:179-222. |
high |
| 227 | :stream loses “atomicity; per-record hooks; per-record notifications” |
It is the reverse. :stream is the only strategy that runs change/3, before_action, around_action and before_transaction per record. It loses concurrency safety and speed (update-actions.md:226). The atomic strategies lose every hook except after_action/after_transaction (ash/lib/ash/actions/update/update.ex:64,80-83). A query with before_action/after_action hooks cannot be updated atomically (ash/lib/ash/actions/update/bulk.ex:98-102). |
high |
| 229 | Selection ladder | set_strategy/3 forces [:stream] when the data layer lacks :update_query. It defaults to all three when :expr_error is missing, and adds :atomic_batches when the input is an enumerable and :atomic was requested: ash/lib/ash/actions/update/bulk.ex:1232-1248 (destroy: ash/lib/ash/actions/destroy/bulk.ex:905-918). Describe this instead. |
medium |
| 231 | lib/ash.ex:3895 “per-record after_action hooks force a non-atomic action” quoted as Ash.bulk_update’s doc |
That is the Ash.update_many docstring (ash/lib/ash.ex:3870-3900). |
medium |
| 104 | Update pipeline “handle_multitenancy → changeset → authorize → add_atomic_constraints / atomic-upgrade decision → commit” | do_run is handle_multitenancy → changeset → authorize → Ash.Changeset.add_atomic_validations → commit (ash/lib/ash/actions/update/update.ex:354-360). The atomic-upgrade decision happens earlier, in run/4 (:53-155), before the :action span opens. |
medium |
| 119 | dirty_hooks = all hooks minus after_action/after_transaction |
dirty_hooks records only hooks added while the changeset phase is :pending, meaning hooks added by the caller rather than by the action’s own changes: ash/lib/ash/changeset/changeset.ex:7741-7747. Hooks that changes add during for_update do not block the upgrade. |
medium |
| 129 | Atomic upgrade “issues a single Ash.DataLayer.update_query” |
It rebuilds the changeset from params with fully_atomic_changeset/4, so each change runs atomic/3. Because Ash.update already ran for_update with change/3 (ash/lib/ash.ex:4159-4165), both callbacks run. It then calls Ash.Actions.Update.Bulk.run with strategy: [:atomic, :stream], authorize_query?: false and authorize_changeset_with: :error when :expr_error is supported. That last option embeds authorization in the UPDATE as an error expression: update.ex:93-127, :211-233. Issue #2969 (2026-09-26) is a real bug from this rebuild. |
high |
| 133 | Destroy: “no require_values phase; the atomics are validations instead” | Update also calls add_atomic_validations (update.ex:358-359). Missing from the document: soft destroys route to Ash.Actions.Update.run (ash/lib/ash/actions/destroy/destroy.ex:19-52). Hard destroy has no atomic upgrade (destroy.ex:55-130, ash/lib/ash.ex:4335-4345). Inside the transaction, destroy loads the record before deleting (destroy.ex:216-224) and calls Helpers.notify inside with_hooks (:276-289), unlike create/update. |
high |
| 87, 468 | Create and update authorize with the same options | Create uses pre_flight?: true (create.ex:221-226); update and destroy use pre_flight?: false (update.ex:395-400, destroy.ex:186-191). All three use the default run_queries?: true (ash/lib/ash/can.ex:165). For non-atomic update/destroy with filter checks, this means a SELECT on primary key + policy filter runs before the transaction opens (can.ex:1591-1596, :1306+). Runtime checks are instead deferred to a prepended before_action inside the transaction (can.ex:1392-1420, :1432-1462). |
high |
| 141, 144 | Generic actions: list complete | Missing: (a) the build phase with preparations and validations (above); (b) there is no around_action; © filter or runtime checks raise (ash/lib/ash/actions/action.ex:405-431); (d) notifications are sent before after_transaction hooks (action.ex:238-252), whereas create/update send them after; (e) authorization runs after before_transaction hooks in both branches (:183-198, :277-280). |
medium |
| 97 | Create post-transaction order | Correct, but say it explicitly: after_transaction runs inside with_hooks, before Helpers.load and before the main resource notification (changeset.ex:4989-4996, then create.ex:565-581). Notifications returned by hooks or nested actions are sent at changeset.ex:4877-4888. The resource’s own notification is sent later, in Helpers.notify (ash/lib/ash/actions/helpers.ex:564-594). |
medium |
| 185 | Read hooks implied to run in insertion order | Read after_action hooks are prepended (reverse order) unless config :ash, read_action_after_action_hooks_in_order?: true; the compile default is false: ash/lib/ash/query/query.ex:250-253, :1612-1620. |
low |
| 191 | Read-side cross-check “matching the code order” | ash/documentation/topics/actions/read-actions.md:104-115 lists “Run before action hooks” before “Strict Check & Filter Authorization”. The code does the opposite (read.ex:579 vs :781). actions.md:635 says global preparations, validations and changes are “no longer part of the core action lifecycle”, yet changeset.ex:3616-3621 and :4328-4344 run them. Report these doc/code contradictions; they are not confirmations. |
medium |
| 26, 239, 591 | DataLayer: “40 callbacks, every one in @optional_callbacks” |
46 @callbacks; 44 are in @optional_callbacks. Required: can?/2 and resource_to_query/2 (ash/lib/ash/data_layer/data_layer.ex:141-377 vs :379-421). The document’s own list contains 46 names. |
high |
| 245 | Feature list complete | Features queried in core but missing from @type feature: :timeout (query.ex:977), :atomic_update (update.ex:578), :changeset_filter, :action_select, :required_error, :nested_expressions, :distinct, :distinct_sort. Note that the type is not exhaustive. :transact appears twice in the type (data_layer.ex:89, :125). |
medium |
| 260 | ETS can? list | It omits {:atomic, :update/:upsert/:create}, :expr_error, :through_relationship, :changeset_filter and {:filter_relationship,_}, all true (ets.ex:241, :285-290). :update/:destroy are true only with a primary key (:249-252). The range is ets.ex:214-291. |
low |
| 261 | Mnesia “wraps ETS” | Ash.DataLayer.Mnesia declares its own @behaviour Ash.DataLayer (ash/lib/ash/data_layer/mnesia/mnesia.ex:6). It answers :transact true (:106) and has no :multitenancy, :update_query/:destroy_query, :lateral_join, :combine or :temporal (:89-151). It is the only built-in data layer that supports transactions. |
high |
| 268 | “ash_sql … has no behaviour of its own” |
AshSql.Implementation is a behaviour with 24 callbacks, including expr/6 for data-layer-specific expression overrides (ash_sql/lib/implementation.ex:7-89). ash_postgres implements it in ash_postgres/lib/sql_implementation.ex:7. Add it to section 3.5 and to the contract table. |
medium |
| 211 | Atomic constraints “installed in the database as a CHECK/raise expression — see §4.3” | They are compiled into the write statement as conditional ash_raise_error(...) expressions, not installed as CHECK constraints (see apply_atomic_constraints, changeset.ex:875-877). Also, ash_raise_error is in §4.2, not §4.3. |
low |
| 302 | ImmutableRaiseError installs “ash_elixir_and/or, ash_raise_error_immutable” | It is opt-in, added to installed_extensions. It installs ash_raise_error_immutable and ash_to_jsonb_immutable (ash_postgres/lib/extensions/immutable_raise_error.ex:6-47). |
low |
| 316 | Snapshot fields from migration_generator.ex:2108-2127 |
Those lines are the empty snapshot used for CreateTable diffs. The real snapshot is built in do_snapshot/3 (ash_postgres/lib/migration_generator/migration_generator.ex:~3877-3897): attributes, identities, table, schema, check_constraints, custom_indexes, custom_statements, repo, multitenancy, temporal, base_filter, has_create_action, create_table_options plus a sha256 hash. It has no empty? field. |
low |
| 324 | emit_review_flags; “refuses to overwrite without --force semantics via create_file(force: true)” |
The function is emit_review_warnings (migration_generator.ex:109). force: true means it overwrites without prompting (:126-129). |
low |
| 334 | expr(...) produces Operator/Function structs at compile time |
The macro emits unresolved %Ash.Query.Call{operator?: true} and ref nodes (ash/lib/ash/expr/expr.ex:826, :839). These are resolved into Ash.Query.Operator.* / Ash.Query.Function.* structs bound to a resource only when the filter is parsed (ash/lib/ash/filter/filter.ex:3727-3876 resolve_call, :4178 hydrate_refs). Describe this two-stage representation. |
medium |
| 345 | Operator behaviour callbacks | to_string/2 is missing (ash/lib/ash/query/operator/operator.ex, 8 callbacks, none optional). Functions have their own behaviour: Ash.Query.Function has 11 callbacks, partial_evaluate/1 optional (ash/lib/ash/query/function/function.ex:19-65). |
low |
| 362 | parent/1 only in “resource-based inline aggregates and filters” |
It is also used in relationship filters and exists, and in the many-to-many join-row special case parent(join_relationship) (ash/documentation/topics/reference/expressions.md, “Many-to-many relationships” section, :233-247). It also changes the lateral-join decision (relationships.ex:1894). |
low |
| 430 | :unsatisfiable gives {:filter, authorizer, false} “unless a strict policy forbids” |
With the code default (config unset), forbidden_due_to_strict_policy?/1 returns true, so statically forbidden reads raise Forbidden. Only no_filter_static_forbidden_reads?: false, which the installer sets, produces the filter (ash/lib/ash/policy/authorizer/authorizer.ex:2232-2264; ash/documentation/topics/development/backwards-compatibility-config.md:86-120; ash/lib/mix/tasks/install/ash.install.ex:186). |
medium |
| 446-456 | SAT solver | Crux sits on an optional backend, picosat_elixir or simple_sat (ash/mix.exs:406-408). Name it. Policy.evaluate/2 is documented as an error-construction helper that reads facts only, not the record-level decision procedure (ash/lib/ash/policy/policy.ex:165-182). The decision comes from Checker.strict_check_scenarios / check_result. |
medium |
| 464-468, 651 | Runtime write path “not read” | It lives in Ash.Can, not in the authorizer: defer_changeset_authorization? (can.ex:1392-1420), a prepended before_action (:1432-1462), and the create path install_create_authorize_results (:1618-1700). Close Open Question 1 with these. |
medium |
| 504-508 | lateral_join?/4 “true when … limit 1 with no context/filter/sort and cardinality ≠ :many …” |
In those limit-1 branches it returns has_parent_expr?, not true. Missing true branches: many-to-many with prefer_lateral_join_for_many_to_many?, and limit || offset || distinct || page, the most common trigger. Manual actions and manual relationships give false: ash/lib/ash/actions/read/relationships.ex:1838-1912. |
medium |
| 512, 617 | “distinct cannot be combined with lateral joins” |
It is inverted. The raise fires when distinct is used without a lateral join (relationships.ex:359-361). distinct forces lateral (has_distinct? → true, :1906). |
medium |
| 516 | read.ex:4056-4090 “dedicated run_aggregate_query_with_lateral_join” |
Those lines are run_count_query/2, the count for paginated relationship loads. Relationship aggregates go through AshSql.Aggregate (lateral vs grouped is still unresolved; Open Question 6). |
low |
| 538-547 | Multitenancy enforcement | Missing: the four action modes :enforce/:allow_global/:bypass/:bypass_all. On reads, :bypass and :bypass_all differ (ash/lib/ash/actions/read/read.ex:2813-2840; ash/lib/ash/resource/actions/read.ex:96-101); writes collapse both to :bypass_all. Update/destroy enforce :attribute as a changeset filter (update.ex:831-843, destroy.ex handle_attribute_multitenancy). |
medium |
| 571 | Error aggregation | Missing: Splode picks the wrapper class by position in error_classes (forbidden, then invalid, then framework, then unknown) and nests the rest (splode/lib/splode.ex:460-482). |
medium |
| 581 | Telemetry list | Core also emits [:ash, :before_transaction] and [:ash, :after_transaction] (changeset.ex:5091, :5233; read.ex:176, :232), which monitoring.md does not list. Say so. |
low |
| 594 | Ash.Type required: type/0, …, load/4 |
There is no type/0 callback. load/4 is optional. 58 callbacks, 25 optional, 33 not optional (they include storage_type/1, cast_input/2, cast_stored/2, dump_to_native/2, ecto_type/0, cast_atomic/2, handle_change/3, …); see §4 recount. |
high |
| 595 | Ash.Resource.Change: “all listed callbacks are declared without an @optional_callbacks entry” |
@optional_callbacks before_batch: 3, after_batch: 3, batch_change: 3, change: 3, atomic: 3, temporal_safe?: 1 (ash/lib/ash/resource/change/change.ex:460-465). |
high |
| 592 | Ash.Authorizer required includes alter_filter/3, add_calculations/3, alter_results/3, protected_fields/1, exception/2 |
Those are optional. Required are initial_state/4, strict_check_context/1, strict_check/2, check_context/1, check/2 (ash/lib/ash/authorizer.ex). The row lists the same callbacks in both columns. |
high |
| 596 | Ash.Resource.Validation row lists validate/3, atomic/3, batch_validate/3, temporal_safe?/1 as both required and optional |
Required: init/1, supports/1, batch_callbacks?/3, atomic?/0, has_validate?/0, has_batch_validate?/0. Optional: describe/1, validate/3, atomic/3, batch_validate/3, temporal_safe?/1 (ash/lib/ash/resource/validation.ex:67-140). |
medium |
| 600-602 | ManualCreate.create/4, ManualUpdate.update/4, ManualDestroy.destroy/4 |
The callbacks are /3 (changeset, opts, context): manual_create.ex:84, manual_update.ex:68, manual_destroy.ex:72. The /4 functions are dispatch helpers. |
medium |
| 604 | SimpleCheck optional: init/1, … |
Ash.Policy.SimpleCheck declares one callback, match?/3 (ash/lib/ash/policy/simple_check.ex:39). The others are Ash.Policy.Check callbacks with use-provided defaults. |
low |
| 626 | Pain point 12 “Preparations cannot see other filters” | No source supports this (query.ex:961 is the run_preparations pipeline call). Remove it or source it. |
low |
| 657 (Open Q 7) | No community evidence | See §7 below. | medium |
2. Corrected lifecycles
Notation: [T] inside the data-layer transaction, [out] outside it.
2.1 Create (Ash.create → Ash.Actions.Create.run/4)
Ash.Changeset.for_createis run by the caller or byCreate.changeset/4(create.ex:272-282). It runs: context,:changesetspan, private args,prepare_changeset_for_action,handle_params(cast, arg defaults, require args),handle_upsert, then action changes and action-level validations interleaved in declaration order, then global changes. Each change’schange/3may add hooks (changeset.ex:3613-3671). Then global validations: immediate, or turned intobefore_actionhooks whenbefore_action?ordelay_global_validations?is set (:4328-4366). Thenmark_validated,eager_validate_identities,require_values(:3052-3073). [out]run/4: reject unsupported atomics (create.ex:19-49); apply multitenancy bypass context (:52-59); open the:actionspan and[:ash,<domain>,:create]telemetry (:64-88).do_run/4:handle_multitenancy,changeset/4,check_upsert_support,authorize(Ash.can,pre_flight?: true,run_queries?: true). Data-dependent filter checks are not resolved here; they are stashed asauthorize_resultshooks (can.ex:1471-1484,:1618-1645), andCannotFilterCreatesis raised if there is no transaction or if there arebefore/around_transactionhooks withoutallow_post_action_authorization?. Thencommit(create.ex:182-186). [out]with_hooks(changeset.ex:4749):around_transaction(first added = outermost) →before_transaction(halts on invalid) → transaction opens, unless no hooks exist and the data layer does not prefer one, or:transactis unsupported (:4754-4766).- [T]
around_action(first added = outermost) →before_action(in order; halts on invalid; thenhydrate_atomic_refs) → body:hydrate_atomic_refs,apply_atomic_constraints,set_action_select,setup_managed_belongs_to_relationships,require_values, thenManualCreate.create/3, orDataLayer.upsert, orDataLayer.create; thenmanage_relationships(create.ex:343-548). - [T]
after_action(in order; may add notifications) →authorize_results(create filter policies:SELECT … WHERE pk AND filter, rollback and Forbidden on a miss) (changeset.ex:5308-5399). - Transaction commits (or rolls back on error).
- [out]
after_transaction(always runs; can rewrite the result), still insidearound_transaction(changeset.ex:4989-5014). - [out] Notifications returned by hooks or nested actions are sent, or queued if an outer Ash transaction is open (
changeset.ex:4855-4890). - [out]
Helpers.load(result loads,reuse_values?), thenHelpers.notifyfor the resource’s own notification (queued if an outer Ash transaction is open), thenselect, thenrestrict_field_access(create.ex:565-581). add_notifications/warn_missed!(create.ex:256-270).
2.2 Update (Ash.update → Ash.Actions.Update.run/4)
Ash.updaterunsfor_updateif the changeset is not yet validated (ash.ex:4159-4165). Same build steps as create: every change’schange/3runs here. [out]run/4rejects atomics if{:atomic,:update}is unsupported (update.ex:36-42), then applies multitenancy bypass.- Atomic-upgrade decision, before any span (
update.ex:53-155). Each condition gives{:not_atomic, reason}: neitherrequire_atomic?noratomic_upgrade?; no:expr_errorwhile authorizing; no:update_query;manage_relationshipsamong dirty hooks; caller-added hooks other than after_action/after_transaction; no read action;atomic_upgrade?: falsein opts.
4a. Atomic path.fully_atomic_changeset/4rebuilds from params and runs every change’satomic/3and atomic validations (changeset.ex:813-890). It re-attachesatomic_after_action/atomic_after_transactionand merges caller filters (update.ex:128-179). ThenUpdate.Bulk.run(query-by-pk, strategy: [:atomic, :stream], authorize_query?: false, authorize_changeset_with: :error | :filter). Authorization becomes an error or filter expression inside the single UPDATE. Zero rows returnsStaleRecord(update.ex:198-255).
4b. Not atomic +require_atomic?+ data layer capable →MustBeAtomic(update.ex:257-269).
4c. Classic path.:actionspan, thendo_run:handle_multitenancy(:attributeadds a changeset filter),changeset,authorize(pre_flight?: false; filter checks run a pk+filter SELECT now, outside the transaction; runtime checks become a prependedbefore_action),add_atomic_validations,commit(update.ex:354-360;can.ex:1591-1596). commit: managed relationships are registered as abefore_actionhook (update.ex:463-477). Thenwith_hooks: around_transaction → before_transaction → [T] around_action → before_action → body (require_values; ifchanged?,DataLayer.updatewithchangeset.filter; otherwise no write, only an optional re-select under the filter,update.ex:575-668; thenmanage_relationships) → after_action → authorize_results → commit → after_transaction.- [out] Hook notifications, then
Helpers.load, thenHelpers.notify, then select, then restrict (update.ex:717-729).
2.3 Destroy (Ash.destroy → Ash.Actions.Destroy.run/4)
soft? true→for_destroythenAsh.Actions.Update.run(whole update lifecycle, including the atomic upgrade) (destroy.ex:19-52).- Hard destroy: no atomic upgrade.
:actionspan, thendo_run: set tenant,handle_multitenancy,changeset(for_destroy),authorize(pre_flight?: false, same pre-flight SELECT / deferred runtime rules as update),add_atomic_validations,commit(destroy.ex:133-160). [out] with_hooks: around_transaction → before_transaction → [T] around_action → before_action → body:Helpers.loadof the record to be deleted, set tenant,ManualDestroy.destroy/3orDataLayer.destroy,manage_relationships, thenHelpers.notifyinside the transaction (queued until the outermost Ash transaction ends) (destroy.ex:216-289) → after_action → authorize_results → commit → after_transaction.- [out] Hook notifications sent; result selected (
destroy.ex:313-326).
2.4 Read (Ash.read → Ash.Actions.Read.run/4)
:actionspan and[:ash,<domain>,:read]telemetry, thenaround_transactionhooks wrap everything that follows (read.ex:66-120).do_run:for_read(load opts,:queryspan, set action/actor/tenant/as_of, cast, arg defaults, require args, global preparations, then action preparations, then global read validations (before_action?ones become hooks), action filter) (query.ex:893-963,:1122-1167).add_field_level_auth, timeout, calc context, pagination,load_and_select_sort, relationship count aggregates,split_and_load_calculations, ensure selected (read.ex:267-418).do_read(read.ex:569):handle_multitenancy,add_select_if_none_exists,authorize_query(Ash.can,pre_flight?: false,filter_with: :filter|:error) → calc context on sort/filter →before_transactionhooks (:576-609).- Transaction opens if
action.transaction?, or a timeout task is used (:653,:1650-1676). - [T?] Hydrate calcs, aggregates, sort and combinations; related-path authorization filters (
relationship_filters,authorize_calculation_expressions,authorize_loaded_aggregates,authorize_sorts,filter_with_related, other-data-layer filters,update_aggregate_filters) (:654-780). - [T?]
before_action(loads added here are ignored with a warning) → count (async, if requested) → paginate → build data-layer query → run query →validate_get→ drop pagination extra → keysets →authorize_results→after_action(reverse insertion order by default) (:781-818). - [T?] Notifications sent or stored →
after_transaction, which runs inside the transaction when one was opened (:819-823). - [out] Transaction closes; queued notifications flushed (
:1716-1720). - [out]
add_read_metadata→load_through_attributes→load_relationships(each relationship is a nested read with its own authorization) → runtime calculations →load_through_attributes→restrict_field_access→ field-auth cleanup → page (read.ex:465-513).
2.5 Generic action (Ash.run_action → Ash.Actions.Action.run/3)
Ash.ActionInput.for_action: cast, defaults, require args, global preparations, then action preparations, then global validations (on: :action), load (action_input.ex:253-271,:1328-1361). [out]:actionspan and[:ash,<domain>,:action]telemetry, thenaround_transaction(action.ex:53-86).- If
transaction?:before_transaction→ transaction opens → [T]authorize(strict only; filter or runtime checks raise) →before_action→run/3(or Reactor) →allow_nil?check →after_action→ commit, or rollback on error (action.ex:181-225,:369-431,:436-515).
If not:before_transaction→authorize→ hooks and run, all outside a transaction (:275-310). - [out] Notifications sent, then
after_transaction(the reverse of create/update ordering) (action.ex:227-258). maybe_loadon the return type (action.ex:148-177).
There is noaround_actionfor generic actions.
3. Check 3: authorization asymmetry (the researcher’s out-of-scope finding)
Both halves are true, and the difference is real.
- Generic:
authorize/3is called inside the function passed toAsh.DataLayer.transaction(action.ex:195-210) and afterrun_before_transaction_hooks(:183). - Create/update/destroy:
authorize/2runs indo_runbeforecommit/3, and therefore beforewith_hooks,before_transactionand the transaction (create.ex:185,update.ex:357,destroy.ex:148). - What the finding leaves out. Writes still do part of their authorization inside the transaction: create filter checks run as
authorize_resultsafterafter_action, and update/destroy runtime checks run as a prependedbefore_action(see §1). On non-atomic update/destroy, filter checks run a SELECT before the transaction, which is a check-then-act gap. On atomic updates the check runs inside the statement itself. So it is not one asymmetry but four different placements, depending on action type and check kind.
4. Recounts
Check 5: Ash.DataLayer
@callbackdeclarations: 46, all distinct, withupsert/3andupsert/4counted separately.@optional_callbacks: 44.- Required:
can?/2,resource_to_query/2. @type feature(): 48 alternatives with:transactlisted twice, so 47 distinct (45 when the three{:atomic, …}alternatives are written as one, as the document does). The document’s 45-item list matches the type completely. The type is not exhaustive: core also queries:timeout,:atomic_update,:changeset_filter,:action_select,:required_error,:nested_expressions,:distinct,:distinct_sort.- Simple: the document’s list is exact (
simple.ex:18-32). - ETS: incomplete (see table). Mnesia: wrong (see table).
Check 11: contract table
The counts come from the @callback and @optional_callbacks declarations.
| Behaviour | Callbacks | Required (not in @optional_callbacks) | Optional | Document row |
|---|---|---|---|---|
Ash.DataLayer |
46 | can?/2, resource_to_query/2 |
44 others | wrong |
Ash.Authorizer |
12 | initial_state/4, strict_check_context/1, strict_check/2, check_context/1, check/2 |
exception/2, add_calculations/3, alter_results/3, alter_filter/3, apply_field_level_auth/3, evaluate_field_policies/3, protected_fields/1 |
wrong (same callbacks listed in both columns) |
Ash.Notifier |
3 | notify/1, requires_original_data?/2 |
load/2 |
correct |
Ash.Type |
58 | 33, including storage_type/1, cast_input/2, cast_stored/2, dump_to_native/2, ecto_type/0, constraints/0, apply_constraints/2, cast_atomic/2, handle_change/3, prepare_change/3, equal?/2, describe/1, can_load?/1, loaded?/4, … (most get defaults from use Ash.Type) |
25, including init/1, storage_type/0, load/4, merge_load/4, operator_overloads/0, … |
wrong (type/0 does not exist; load/4 is optional; dump_to_native/2 omitted) |
Ash.Resource.Change |
13 | init/1, batch_callbacks?/3, atomic?/0, has_change?/0, has_batch_change?/0, has_after_batch?/0, has_before_batch?/0 |
change/3, atomic/3, batch_change/3, before_batch/3, after_batch/3, temporal_safe?/1 |
wrong |
Ash.Resource.Validation |
11 | init/1, supports/1, batch_callbacks?/3, atomic?/0, has_validate?/0, has_batch_validate?/0 |
describe/1, validate/3, atomic/3, batch_validate/3, temporal_safe?/1 |
contradictory |
Ash.Resource.Preparation |
4 | init/1, prepare/3, supports/1 |
temporal_safe?/1 |
correct |
Ash.Resource.Calculation |
7 | init/1, describe/1, load/3, strict_loads?/0, has_expression?/0 |
expression/2, calculate/3 |
correct |
Ash.Resource.ManualRead |
2 | read/4 |
load_relationships/5 |
correct |
Ash.Resource.ManualCreate/Update/Destroy |
2 each | create/3, update/3, destroy/3 |
bulk_create/3, bulk_update/3, bulk_destroy/3 |
arity wrong (document says /4) |
Ash.Policy.Check |
13 | strict_check/3, describe/1, prefer_expanded_description?/0, requires_original_data?/2, type/0, eager_evaluate?/0, init/1 |
check/4, auto_filter/3, expand_description/3, simplify/2, implies?/3, conflicts?/3 |
correct |
Ash.Policy.SimpleCheck |
1 | match?/3 |
none | partly wrong |
Ash.Policy.FilterCheck |
2 | filter/3 |
reject/3 |
correct |
Ash.Tracer |
9 | start_span/2, stop_span/0, get_span_context/0, set_span_context/1, set_metadata/2 |
set_error/1, set_error/2, trace_type?/1, set_handled_error/2 |
correct |
Ash.CustomExpression |
3 | expression/2, name/0, arguments/0 |
none | correct |
Run-time behaviours missing from the table:
| Behaviour | Callbacks | Source |
|---|---|---|
Ash.Resource.Actions.Implementation (generic action body) |
run/3 |
ash/lib/ash/resource/actions/action/implementation.ex |
Ash.Resource.ManualRelationship |
select/1, load/3 |
ash/lib/ash/resource/manual_relationship/manual_relationship.ex |
Ash.Query.Operator |
8, none optional | ash/lib/ash/query/operator/operator.ex |
Ash.Query.Function |
11, partial_evaluate/1 optional |
ash/lib/ash/query/function/function.ex |
Ash.Filter.Predicate |
compare/2, bulk_compare/1, simplify/1, all optional |
ash/lib/ash/filter/predicate.ex:24-48 |
Ash.Resource.Aggregate.CustomAggregate |
describe/1 |
ash/lib/ash/resource/aggregate/custom_aggregate.ex:12 |
Ash.Type.NewType |
4 | ash/lib/ash/type/new_type.ex |
AshSql.Implementation |
24, determine_types/3 optional |
ash_sql/lib/implementation.ex |
5. Citation sample statistics
I opened 71 cited path:line references, including the five ash_sql/lib/expr.ex rendering citations.
| Verdict | Count | % |
|---|---|---|
| Supports | 41 | 58% |
| Wrong line but true | 13 | 18% |
| Does not support the claim | 17 | 24% |
| Nonexistent | 0 | 0% |
- Wrong line but true:
create.ex:222-242(is 219-239),:50-56,:157-171(is 182-186),:114-123(is 124-133),:354-380,:553-571(is 565-581);changeset.ex:5110(is 5119),:5303(is 5308);action.ex:86-92;read.ex:809-812(is 821),:4553-4556(is 4560-4562);ets.ex:214-275(is 214-291); all fourmix.exslines. - Does not support:
update_many.ex:8-31andlib/ash.ex:3895(both cited for bulk_update);data_layer.ex:379-420(for “all optional”);read.ex:88-115(for “no transaction”);read.ex:886(wrong function);relationships.ex:359-361(claim inverted) and:1838-1905(branches misdescribed);set_primary_actions.ex:155;verify_actions_atomic.ex:189-195; the uncited Mnesia line;migration_generator.ex:2108-2127;immutable_raise_error.ex:7-40;authorizer.ex:2235-2244;change.ex(contract row);authorizer.ex:17-95(required list);manual_create.ex:84-108(arity);type.ex:311-624.
6. Acceptance criteria
| # | Criterion | Status | What is missing or wrong |
|---|---|---|---|
| 1 | Action lifecycle | partial | Read transaction and read authorization order are wrong. Generic build phase and ordering are missing. The update atomic path is wrong. Destroy specifics (soft destroy routing, load and notify inside the transaction) are missing. authorize_results is missing. Notification and load order relative to after_transaction is not stated. Contradictions with the guides are not reported. See §2. |
| 2 | Atomic and bulk | partial | Strategy semantics and defaults were taken from update_many. The “lost” column is inverted. Default actions use a different require_atomic? default. The verifier warns rather than rejects. The double change/3 + atomic/3 execution is missing. |
| 3 | Data layer contract | partial | Counts are wrong, and the two required callbacks are missed. Mnesia is wrong. Non-type features are omitted. The AshSql.Implementation split is missing. |
| 4 | AshPostgres | complete (minor fixes) | Snapshot fields, review-warnings name, force: true meaning, ImmutableRaiseError contents. |
| 5 | Expression engine | mostly complete | The Call→Operator/Function hydration stage is missing. parent/1 scope is too narrow. The Function behaviour is missing. |
| 6 | Policy engine | partial | The unsatisfiable default is wrong. The write-path placement (pre-flight SELECT, deferred runtime check, create authorize_results) is missing although it is readable in can.ex. Policy.evaluate is misattributed. The SAT backend is not named. |
| 7 | Loading engine | partial | The lateral-join conditions are wrong, and the distinct claim is inverted. Aggregate strategy (grouped vs lateral) is unresolved. |
| 8 | Multitenancy | partial | Action multitenancy modes, the update/destroy changeset filter, and the read-side :bypass vs :bypass_all difference are missing. |
| 9 | Notifiers | mostly complete | Order relative to after_transaction and load is missing. Destroy notifies inside the transaction. Generic actions notify before after_transaction. |
| 10 | Errors and observability | mostly complete | Splode class precedence is missing. The undocumented before_transaction/after_transaction telemetry events are missing. |
| 11 | Contract table | partial | Six rows are wrong (see §4). Eight run-time behaviours are missing. |
| 12 | Pain points | partial | No community evidence. Add the items in §7. Remove or source in-tree item 12. |
7. Pain points with community evidence (check 13)
I re-verified the titles and dates of #1565, #2577, #2969, ash_postgres#215 and forum topic 71027 with gh and the forum JSON. The rest come from a read-only research pass that opened each source.
- Load overhead far above the SQL time. ash#1565, 2024-10-30, @nallwhy, “Significant latency in Ash.load”: “too slow (>= 10s), but db query is not slow. (~= 200ms)”. Closed as not reproduced; Zach: “we perform only slightly worse than ecto”. https://github.com/ash-project/ash/issues/1565
- Calculations with
loadwere 10x slower than hand loading. ash#1939, 2025-04-02, @jechol: “performance is approximately 10 times slower compared to calculations using manual relationship loading”. Closed after a fix (“Fixed (for the most part) in 0f585a3”), with about 10 ms of overhead remaining. https://github.com/ash-project/ash/issues/1939 - Expression calculations hydrated three times per load. ash#1444, 2024-09-07, @pinetops: “Hydrating expressions is relatively expensive: .5ms is typical in a real app for a first_name <> last_name”. Still open. https://github.com/ash-project/ash/issues/1444
- Bulk create and embedded-resource memory blow-up. Forum topic 60980, 2024-01-14, @sezaru: “it takes around 36 seconds to insert the 10_000 rows… the same bulk_create call takes 6 seconds” without embeds. Follow-up topic 65378, 2024-08-07, @arconautishche: “the spike reaches over 30GB on a dev machine”. Fixed by an embeds fast path and the
include_embedded_source_by_default?config. https://forum.elixirforum.com/t/60980, https://forum.elixirforum.com/t/65378 (2024; older than the preferred window) - Query shape:
&&compiles toash_elixir_and()and skips indexes. Forum topic 71027, 2025-05-27, “Filter with “&&” (ash_elixir_and) slower than “and” in PostgreSQL”. About 3400 ms vs about 110 ms. By design; Zach: “You should prefer to use and whenever possible”. This shows the cost of the SQL functions in §4.2. https://forum.elixirforum.com/t/71027 - Query shape: a cartesian product from
parent()refs combined withor. ash#2577, 2026-02-20, @nallwhy: “if one parent relationship has N rows and another has M rows, the intermediate result has N×M rows”. Open. https://github.com/ash-project/ash/issues/2577 Related: ash_postgres#215 (2024-02-28), where afirstaggregate used in a sort generated “a rather inefficient and slow query” (LATERAL + array_agg). https://github.com/ash-project/ash_postgres/issues/215 - N+1:
manage_relationshipworks one record at a time. ash#1581, opened 2024-11-05; comment by @wjrtz: “insert a record with 500k child resources through manage_relationship. This leads to 500k queries”. Open, partly optimized; Zach: “it’s the update case that still needs to be done”. https://github.com/ash-project/ash/issues/1581 - Atomic confusion and the
require_atomic?default. Forum topic 65329, 2024-08-04, @rapidfsub: “Just one use of “manage_relationship” makes action not able to be atomic. I think the default value of require_atomic? should be “false”.” By design; Zach cites concurrency safety. Also ash#1770 (2025-02-06): “must be atomic, but could not be done atomically: … Cannot cast a non-literal list atomically”. https://forum.elixirforum.com/t/65329, https://github.com/ash-project/ash/issues/1770 - The atomic path silently diverges from the non-atomic path. ash#2969, 2026-09-26, @dmy-gh, “Filters added by an action’s changes are dropped when a single-record update runs atomically”: “change filter(…) … has no effect when Ash.update/2 is called on a record and the action runs atomically, which is the default.” Closed. This is a direct consequence of the rebuild in §2.2 step 4a, and of the comment at
update.ex:166-167. Related: ash#2654 (2026-03-31), whereget_data/2“silently returns nil” in atomic or bulk updates. https://github.com/ash-project/ash/issues/2969, https://github.com/ash-project/ash/issues/2654 - Policy and debugging surprises.
- Forum topic 65695, 2024-08-27, @rapidfsub: “In some cases, raise error and in other cases, query and filter confuses me.” This is the static-forbid-versus-filter behaviour that the corrected 6.3 row describes. https://forum.elixirforum.com/t/65695
- ash#2729, 2026-05-29: child read policies are skipped inside calculations but applied in aggregates. By design: “within a calculation, policies are not applied to related access items”. https://github.com/ash-project/ash/issues/2729
- ash#2275, 2025-08-18: a policy that loads related data produces an invalid atomic query, “update_all does not allow subqueries in from”. Open; the workaround is
require_atomic? false. https://github.com/ash-project/ash/issues/2275 - Debugging: forum topic 72640, 2025-09-24, on Reactor-wrapped errors: “the only information I can get is the step name of the error occured”. https://forum.elixirforum.com/t/72640
Gap: I found no dedicated N+1 issue for the read/load path. Item 7 is the closest evidence.
8. Smaller notes for the researcher
- The status report says “28 Operation types”; the document lists 27, and the source has 27 (
ash_postgres/lib/migration_generator/operation.ex). The document is right; fix the report. - The researcher’s “typo” finding is confirmed:
tenant: changeset.context[:private][:actor]appears atchangeset.ex:5132,:5222and:5323. The report’s line numbers are off by one: it says 5133, 5215 and 5327. - In §1.7, quote the guides’ own lifecycle (
ash/documentation/topics/actions/actions.md:392-640) and list where the code disagrees with them, rather than asserting a match.
Round 2 re-verification
Re-checked on 2026-10-01 against the same clone. The ash clone is at commit 2020452 (2026-10-01), so it already includes the #2969 fix 3f707b0. Document checked: 861 lines, 13,465 words. The revision log says “~11,900”; correct that figure.
New verdict: ACCEPT-WITH-FIXES. The five lifecycles are now correct in order, function and placement. The contract and data-layer inventories match the declarations, and all 10 community items check out. The remaining problems are three un-applied corrections that the revision log claims were made, one wrong claim in the new §6.6, the bulk_create scope error in §2.3, internal inconsistencies, and stale line numbers in §1.1. None of them changes the architecture, but each must be fixed before the engine design reads from this document.
R2.1 The three disagreements in the revision log
The researcher is right on all three. I have corrected my round 1 table above:
Ash.Resource.Change@optional_callbacksis atchange.ex:460(lines 460-465), not 446-451.:transactappears atdata_layer.ex:89and:125, not 91 and 126.- The guide’s “:stream is naturally slower” text is at
update-actions.md:226, not 217-219.
All three were line numbers only; the content was right in both versions.
R2.2 Lifecycles (item 1)
All checked step by step:
- Read:
do_read;authorize_queryat :579 beforebefore_transactionat :609; the transaction whentransaction?is set (:1650-1676);after_transactioninside the transaction. - Update: rebuild via
fully_atomic_changeset; routing throughUpdate.Bulk.runwith[:atomic, :stream];MustBeAtomic; the classic path. - Destroy: soft destroy goes to
Update.run; load and notify inside the transaction. - Generic: build-phase preparations and validations; no
around_action; notifications beforeafter_transaction.
Order and functions are correct. What is still wrong is line numbers only:
| Doc L | Cited | Correct |
|---|---|---|
| 80 | create.ex:50-56 |
create.ex:52-59 |
| 81 | create.ex:62-82 |
create.ex:64-88 |
| 84 | create.ex:269-279 |
create.ex:272-282 |
| 91 | create.ex:281-539 (commit) |
create.ex:284-586 |
| 93 | create.ex:322-339 (with_hooks options) |
the call is at create.ex:324-326; the options are at :550-563 |
| 94 | create.ex:340-521 |
create.ex:343-548 |
| 95 | create.ex:354-380, :597-640 |
create.ex:376-403; validate_manual_action_return_result! at :588-620 |
| 96 | create.ex:461-495 |
create.ex:455-501 |
| 139 | update.ex:236-249 (StaleRecord) |
update.ex:241-251 |
| 150 | destroy.ex:313-326 |
those lines are transaction_metadata; the result handling is destroy.ex:318-334 |
| 166 | update.ex:80-84 for :manage_relationships in dirty_hooks |
update.ex:77-78 |
| 577 | create.ex:166, :319, :299-315 |
create.ex:182, :314, :285-312 |
R2.3 §6.6 write authorization (item 2)
Six is the right count as a classification: the four non-atomic create/update/destroy placements, plus atomic update, plus generic. My round 1 “four” counted only the non-atomic create/update/destroy paths. Still wrong:
- Row 3 (update/destroy runtime checks) misstates the fallback. It says the check “raises rather than running … if the action will not transact”. That is false.
defer_changeset_authorization?returns false (do not defer) when the resource is already in a transaction, when the data layer lacks:transact, or whenaction.transaction? == false(can.ex:1392-1408). The check then runs immediately at pre-flight viarun_changeset_query(can.ex:1591-1596). Outside a caller’s transaction, that is a pre-flight SELECT with no transaction, the same check-then-act gap as row 4. Onlybefore_transaction/around_transactionhooks raise (can.ex:1409-1419), and so does the installed hook if it fires outside a transaction (can.ex:1438-1460). Rewrite the row to give these three outcomes. - Row 1 cites the wrong code.
can.ex:1591-1645is the filter and create-stash code. Strict checks run inrun_check/4, viaAsh.Authorizer.strict_check(can.ex:1027-1300, call at:1083). - The count is inconsistent across the document. The summary (L23) says “four placements”, §6.6 (L483) says “six” and Implications #7 (L702) says “five”. Use six everywhere.
- §6.5 L479 is stale. It still cites
create.ex:222-242; the correct range is:219-239. “Runtime checks then run inside the transaction” contradicts row 3; point it to §6.6. - Gap. Bulk paths are not covered. Bulk create runs create filter checks as per-record
authorize_results(policies.md“Bulk creates” section,:316-322), and bulk update/destroy authorize the query. One sentence pointing to this is enough.
R2.4 Bulk strategies (item 3)
- L224:
Ash.bulk_create/4has no:strategyoption. Its option schema (ash/lib/ash.ex:673-741) contains nostrategykey. It batches inputs and callsAsh.DataLayer.bulk_createwhen:bulk_createis supported (ash/lib/ash/actions/create/bulk.ex:186). Changesets witharound_transaction/around_actionhooks are routed one by one through the single-record path. Every other changeset getsbefore_transactionper changeset, then batchedbefore_action, data-layerbulk_create,after_actionandafter_transaction(ash/lib/ash/actions/helpers.ex:18-37;create/bulk.ex:1187,:1589,:1647). Remove bulk_create from the strategy table and give it its own row. - L224: the
Ash.update_manydescription is wrong. It does not take “per-record inputs derived from a query”; it takes{record_or_identifier, input}tuples (ash.ex:3870-3878). - L235: destroy’s capability check is mis-stated.
set_strategyfor destroy tests:update_query(not:destroy_query) together with:expr_error, and otherwise forces[:stream](ash/lib/ash/actions/destroy/bulk.ex:906-912). Name the actual capabilities; this looks like an upstream quirk. - Defaults (
[:atomic]for update,:atomicfor destroy) and the keeps/loses columns are now correct.
R2.5 Contracts (item 4)
Ash.DataLayer: correct. 46 callbacks; the 44 optional ones listed item by item matchdata_layer.ex:379-421; requiredresource_to_query/2at :180 andcan?/2at :364. The feature list matches the type item by item, and the non-type feature list matches my grep.- Contract table: every row matches
@callback/@optional_callbacksexceptAsh.Policy.FilterCheck(L642). It still listsinit/1, requires_original_data?/2, prefer_expanded_description?/0, eager_evaluate?/0as optional callbacks. FilterCheck declares onlyfilter/3and the optionalreject/3; the rest areAsh.Policy.Checkcallbacks with defaults fromuse. Fix it the way SimpleCheck was fixed. - The 8 added behaviours: all correct, including the cited lines (
implementation.ex:60,manual_relationship.ex:35-44,operator.ex:23-64,function.ex:19-65,predicate.ex:24-48,custom_aggregate.ex:12,new_type.ex,ash_sql/lib/implementation.ex:7-89).
R2.6 §1.7 (item 6)
Both contradictions are verified:
read-actions.md:104-115lists before-action hooks before authorization; the code does the reverse (read.ex:579vs:781).actions.md:635says global preparations, validations and changes are outside the lifecycle;changeset.ex:3616-3621and:4328-4344run them.
The extra note on actions.md:636 and hook order is also correct.
R2.7 Community pain points (item 7)
I fetched all of them. Every quote, author and date matches:
- GitHub: #1565, #1939, #1444, #2577, ash_postgres#215, #1581 (@wjrtz 2025-12-30; @zachdaniel 2026-03-21), #2969, #2729, #2275 (@StrongFennecs 2025-08-18 and 2025-08-25).
- Forum: 60980, 65378, 71027 (Zach’s “elixir-ish” reply found), 65329, 65695, 72640.
- Wording: for #1770 and #2654 the quoted strings are the issue titles. Label them as titles, not quotes.
- #2969 is described accurately. It was reported on Ash 3.33.11. The fix is commit
3f707b0, “fix: properly combine filters during atomic upgrade”, 2026-09-26, which touches onlyupdate.exand its test. It corresponds to the merge atupdate.ex:166-179. Add one fact: the issue says caller filters set beforefor_updatestill applied; only filters added by the action’s own changes were lost.
R2.8 Round 1 rows not fixed, despite the revision log (item 8)
Everything else in the round 1 table was fixed correctly: expression engine, policy, multitenancy, notifiers, errors, observability, AshPostgres. Not fixed:
- §7.1 L546 still says “
distinctcannot be combined with lateral joins”. This contradicts L541 and L664 in the same document, and the revision log says it was corrected. Delete it, or replace it with L664’s wording. - §7.2 L550 still calls
read.ex:4056-4090a dedicated lateral-join aggregate path that dispatches onlateral_join_source. Those lines arerun_count_query/2, the count query for paginated relationship loads (therun_aggregate_query_with_lateral_joincall is atread.ex:4079). Relationship aggregates are planned byAshSql.Aggregate(see R2.10c). The revision log says this was corrected; it was not. - L706 cites
ash_functions.ex:4for the version constant, and L315 citesash_functions.ex:5for the error prefix. The correct lines are:6(@latest_version 7) and:7(@error_prefix). - L212 cites
changeset.ex:875-877forapply_atomic_constraints. Those lines are its call site insidefully_atomic_changeset; the definition is atchangeset.ex:3880. Cite both.
R2.9 Fresh citation sample (item 9): 40 citations not checked in round 1
| Verdict | Count | % |
|---|---|---|
| Supports | 30 | 75% |
| Wrong line but true | 7 | 17.5% |
| Does not support | 3 | 7.5% |
| Nonexistent | 0 | 0% |
- Supports:
changeset.ex:5387-5395,:4877-4888;notifier.ex:180-196;destroy.ex:276-289;read.ex:819-823,:1716-1720;action_input.ex:253-271,:1328-1361;query.ex:1122-1167,:250-253;can.ex:1392-1462,:1591-1596,:1618-1700,:1471-1484,:165;update.ex:93-233,:128-179,:831-843,:463-477;set_primary_actions.ex:18-22;ash.ex:574-578,:634-639,:4159-4165;data_layer.ex:180,:364;expr.ex:826/839;filter.ex:3727-3876;mix.exs:406-408;action.ex:227-258,:405-431. - Wrong line but true:
update.ex:236-249,update.ex:80-84,ash_functions.ex:5,destroy.ex:313-326,changeset.ex:875-877,create.ex:166,create.ex:319. - Does not support:
can.ex:1591-1645cited for strict checks;can.ex:1392-1420cited for “raises if the action will not transact”;ash.ex:574-578withupdate-actions.mdcited as coveringbulk_create.
R2.10 The open gaps, settled from source (item 10)
a) fully_atomic_changeset/4 merge logic (ash/lib/ash/changeset/changeset.ex:813-890):
- It builds a new changeset carrying the original
data(or%OriginalDataNotAvailable{}), the context, params, action,no_atomic_constraintsand tenant. opts[:atomics]arrives fromupdate.ex:120-124as the original changeset’satomic_changesmerged with itsattribute_changes. So attribute values thatchange/3set duringfor_updateare carried into the atomic changeset as literal atomics; only hooks, filters and other side effects ofchange/3are dropped. This is exactly why #2969 happened, and why the fix merges filters.- The pipeline is:
verify_notifiers_support_atomic(notifiers requiring original data block atomicity,:1099) →atomic_params(:1813) →set_argument_defaults→require_arguments→atomic_changes(:1115-1162) →atomic_update(opts[:atomic_update])→ setchanged?→atomic_defaults(static and lazyupdate_defaults as conditional atomics,:892+) →hydrate_atomic_refs(adds atomic validations, fills templates, hydrates refs, extracts eager errors;error_is_not_atomic?: true) →apply_atomic_constraints(attribute type constraints compiled to expressions, def at:3880). atomic_changesruns, in order: action changes and validations as declared, then global changes, then global validations (unlessskip_global_validations?), each viarun_atomic_change/run_atomic_validation. The first{:not_atomic, reason}halts the pipeline.
b) Managed-relationship upsert semantics (ash/lib/ash/actions/managed_relationships.ex, 3,693 lines):
load/4(:20-35) loads current related records before diffing. A freshly created parent normally skips this, butcould_be_related_at_creation?is set when the parent action is an upsert (engine_opts[:upsert?]), so related records are loaded and diffed even on create.manage_relationships/4(:576-600) processes each relationship’s inputs inopts[:meta][:order]order. It matches inputs to existing related records and applieson_lookup/on_no_match/on_match/on_missing, with one nestedAsh.create/Ash.update/Ash.destroy/Ash.read_oneper input (e.g.:537,:1723,:1989,:2106). This is the N+1 of #1581.- A bulk path (
Ash.bulk_create,:1361,:1509,:1638) is used only whenopts[:bulk?] == true,on_no_matchis{:create, ...}, and the destination supports:bulk_create; for one-step creates it also requires not many-to-many (can_bulk_create?/2,:879-890). Updates and destroys are always sequential, which matches Zach’s 2026-03-21 comment.
c) AshSql.Aggregate, grouped vs lateral. Chosen per resource by AshSql.Implementation.aggregate_strategy/1 (ash_sql/lib/implementation.ex:75-87). The default is :lateral (:110). ash_postgres keeps the default; ash_sqlite returns :grouped (ash_sqlite/lib/sql_implementation.ex:13). Dispatch happens in AshSql.Aggregate.strategy/2 (ash_sql/lib/aggregate.ex:146-150) to AshSql.Aggregate.Lateral or AshSql.Aggregate.Grouped. Per the docstring, grouped means “grouped and windowed subqueries … for databases without lateral joins”, and it assumes SQLite-compatible SQL. So on Postgres, related aggregates are always lateral subqueries.
d) Ash.Policy.Authorizer.check_result/1 (it is /1, not /2: ash/lib/ash/policy/authorizer/authorizer.ex:2006-2034; check/2 calls it at :755-757):
- For each record it drops scenarios that are impossible for that record (
scenario_impossible?/3,:2112). - If no scenario is left, the record is forbidden.
- Otherwise
do_check_result(:2074-2086) accepts the record if any scenario applies: every clause is a known fact, or the record’s primary key is indata_factsfor that clause (:2088-2110). - Otherwise
check_facts_until_known(:2136-2157) picks the next unknown fact, runscheck_fact, and loops until some scenario applies or none remain. check_fact(:2159+) callsAsh.Policy.Check.check/4once over all records, a batch call, and stores the authorized primary keys indata_facts. It raises for a non-read action unless the data layer supports:transactand the resource is currently in a transaction.- The result is
:authorizedif no record was forbidden, otherwise{:data, allowed_records}. Reads use{:data, …}to filter; for writes,Ash.Cantreats it as forbidden.
R2.11 Did the rewrite lose correct content? (item 11)
No. Every correct section from round 1 is still present: data layer split, AshPostgres functions, migration generator, templates, runtime evaluation, field policies, breakdowns, Splode, telemetry. The original §1.7 was rightly replaced, and the old pain point 12 was replaced with a sourced item. The only regressions are the two stale paragraphs in R2.8 (items 1 and 2), which survived next to their corrected versions and now contradict them.
R2.12 Fix list (everything still wrong)
- §7.1 L546: delete the “distinct cannot be combined with lateral joins” sentence.
- §7.2 L550: re-describe
read.ex:4056-4090asrun_count_query/2, and add the lateral/grouped answer from R2.10c. - §6.6 row 3: give the three real outcomes of
defer_changeset_authorization?(R2.3 item 1). Fix the row 1 citation tocan.ex:1027-1300/:1083. Use “six” in the summary (L23) and in Implications #7 (L702). Update §6.5 L479. Add one line on bulk paths. - §2.3: remove
bulk_createfrom the strategy scope and describe its batching and hooks (R2.4). Fix theupdate_manydescription. Name:update_query+:expr_errorfor destroy. - §11 FilterCheck row: optional callbacks are
reject/3only. - §12.B: label the #1770 and #2654 strings as titles. Add that #2969 affected only filters added by the action’s own changes.
- Line numbers listed in R2.2 and in R2.8 items 3 and 4.
- Close Open Questions 2, 3, 6 and 9 and the revision log’s “Not fixed” list with R2.10. Open Question 9 is settled by
read.ex:3932-3958pluswarn_if_before_action_load_changed: the loads are collected on the query but ignored, with a warning. - Revision log: correct the word count, and remove the claims that §7.1’s distinct sentence and §7.2’s
read.ex:4056-4090description were corrected.
Round 3 final check
Checked on 2026-10-01. Document: 930 lines, 15,625 words. Same clone (ash at 2020452).
Final verdict: ACCEPT-WITH-FIXES. No further revision round will run. The document is reliable for the run-time design provided readers apply the residual-errors list below. None of the residuals changes a lifecycle order or a contract. Two of them (residual errors 1 and 2) are substantive. The rest are citation or wording slips.
R3.1 Round 2 “still wrong” items
| Item | Status | Evidence |
|---|---|---|
§7.1 distinct and lateral joins |
Fixed correctly | L571: distinct forces a lateral join; the raise fires without one (relationships.ex:359-361, :1906) |
§7.2 read.ex:4056-4090 |
Fixed wrongly (partly) | The lines are now correctly called run_count_query/2. But L575 also says the lateral aggregate callback is “called from the read path at read.ex:4079”, and read.ex:4079 is inside run_count_query (:4062-4090). It is the only core caller. See residual error 2. |
| §6.6 six placements, stated consistently | Fixed | L23, L493 and L729 all say six |
| §6.6 runtime-check row and the three outcomes | Fixed, one sub-case wrong | Outcomes (a) and © are right. Outcome (b) says the non-deferred check runs “outside any transaction”. That is wrong when the resource is already in a caller’s transaction. See residual error 1. |
| §6.6 strict-check citation | Fixed | can.ex:1027-1300, strict_check call at :1083 |
| §6.5 | Fixed | L489 points to §6.6 and states the condition |
§2.3 bulk_create has no :strategy |
Fixed | ash.ex:673-741; given its own row. Hook granularity is slightly misstated (residual error 4). |
§2.3 update_many input |
Fixed | {record_or_identifier, input} tuples, ash.ex:3868-3900 |
§2.3 destroy tests :update_query + :expr_error |
Fixed | destroy/bulk.ex:906-912 |
FilterCheck contract row |
Fixed | L669: filter/3, with reject/3 the only optional callback (filter_check.ex:43-45) |
| #1770 and #2654 relabelled as titles | Fixed | L711 and L712. The #2654 body quote (“template has no actual record data loaded”) is verified in the issue body. |
| Stale line numbers (R2.2, R2.8 items 3 and 4) | Fixed | All verified: create.ex:52-59, :64-88, :272-282, :284-586, :324-326/:550-563, :343-548, :376-403, :455-501, :182, :314; update.ex:241-251, :77-78; destroy.ex:318-334; ash_functions.ex:6/:7; changeset.ex:3880. Not in my round 2 list and still stale: L168 and L635 (residual errors 5 and 6). |
R3.2 The four closed gaps
All four match source and match my round 2 answers:
- §1.2 atomic rebuild. The
withpipeline is atchangeset.ex:857-879, so the cited:860-880is acceptable. Also verified:%OriginalDataNotAvailable{}at:831,atomic_defaultsat:895, and theupdate.ex:120-124merge ofatomic_changeswithattribute_changes. - §1.5 managed relationships.
managed_relationships.ex:20-35,:28-29,:576-600,:879-890and the nested-action sites are correct. - §7.2 aggregate strategy.
implementation.ex:75-87,:110,aggregate.ex:146-150andash_sqlite/lib/sql_implementation.ex:13are correct. - §6.7
check_result/1. The algorithm is correct. The quoted raise message is not verbatim (residual error 3).
R3.3 Lifecycles
Round 3 changed only the citations and the inserted §1.2 and §1.5 blocks. Every step, its order and its function name for create, update, destroy, read and generic are unchanged from the verified round 2 text. The new create lines are correct.
R3.4 Fresh sample: 30 citations not checked before
| Verdict | Count | % |
|---|---|---|
| Supports | 22 | 73% |
| Wrong line but true | 6 | 20% |
| Does not support | 2 | 7% |
| Nonexistent | 0 | 0% |
- Supports:
calculations.ex:1015-1022,:1210-1250,:557-592;read.ex:481-486,:310;relationships.ex:1863-1867,:396-400,:412-437,:556-561,:1470-1480;ref.ex:7-16;filter.ex:242;eq.ex:52-63;custom_expression.ex:71-101;fragment.ex:30-45;calculation.ex:209-224;multitenancy.md:95-102,:245-253;pub_sub.ex:8-80;ash_postgres/lib/data_layer.ex:63-80;policies.md:316-322;policy.ex:17-25. - Wrong line but true:
data_layer.ex:1538-1546(data_layer_can?/2is at:464);call.ex:5-7,eq.ex:15,boolean_expression.ex:7andnot.ex:6(these point at moduledocs or options, not the struct definitions);calculations.ex:531-534(run_calculatestarts at:534). - Does not support:
policies.md:834-836(cited foralter_filternil-rewriting; those lines are aboutcan_see_fields?);changeset.ex:63-90(cited for error aggregation; it is thedefstruct).
R3.5 Lost or broken content
None. All sections from round 2 are present. The corrected §7.1 branch list, §12.A item 3, §6.6 and §1.7 survived. The only new error is the self-contradiction in §7.2 (residual error 2).
Residual errors (for readers)
Treat everything else in 03-ash-runtime-internals.md as verified. The statements below are still wrong; use the correct fact given.
- L499 (§6.6, runtime-check row, outcome b); also L23 and L729. The document says that when
defer_changeset_authorization?returns false, the check runs “as a SELECT outside any transaction”. That is true only when the data layer lacks:transactoraction.transaction? == false. When the resource is already in a transaction opened by the caller, the immediaterun_changeset_querySELECT runs inside the caller’s transaction, before the action’s own hooks (can.ex:1400-1401, then:1591-1596). So the check-then-act gap applies only to the no-transaction cases. - L575 (§7.2). The document says the lateral variant
run_aggregate_query_with_lateral_join/5is “called from the read path atread.ex:4079” and also thatread.ex:4056-4090“is not that path”. These contradict each other. The fact: the only core call ofAsh.DataLayer.run_aggregate_query_with_lateral_joinis atread.ex:4079, insiderun_count_query/2(read.ex:4062-4090). It counts per parent for paginated relationship loads. Aggregates requested in a load are added to the main data-layer query (add_aggregates/AshSql.Aggregate, lateral or grouped per the next paragraph), not run through this callback. - L517 (§6.7). The quoted raise text is not verbatim. The source message is: “Attempted to use a
check/4function on a non-read action with a resource who’s data layer does not support transactions or is not currently in a transaction. … Authorization over create/update/destroy actions for resources that don’t support transactions must only be done with filter and/or strict checks.” (policy/authorizer/authorizer.ex:2184-2190; condition at:2163-2165). - L246 (§2.3,
bulk_createrow). “before_action/after_action/after_transactionper batch” is wrong. These are each changeset’s own hooks, run per changeset while the batch is iterated (create/bulk.ex:1183-1187maps over the batch and callsrun_before_actions(changeset);:1589run_after_actions(result, changeset, []);:1647run_after_transactions). The data-layerbulk_createcall itself is per batch. - L168 (§1.5). The cited
create.ex:340-353,:390-399,:508-517are off. Usecreate.ex:350-355(setup_managed_belongs_to_relationships) and:405-409,:449-453,:497-501,:513-517(manage_relationshipscalls after manual, result, upsert and create respectively). - L635 (§10.1). Wrong citations for exception wrapping and aggregation. The correct ones are:
create.ex:124-133, not:114-123;read.ex:123-132, not:118-127;- the update rescue at
update.ex:341-350(update.ex:29is the invalid-changeset error path); changeset.ex:63-90is the changesetdefstructand does not show aggregation; aggregation iscreate.ex:205-215plussplode/lib/splode.ex:460-482.
- L481 (§6.5).
policies.md:834-836does not support “alter_filter/3rewrites refs to forbidden fields into nil”. Rely onauthorizer.ex:796-808; the guide’s statement about filters is atpolicies.md:838. - L234 (§2.3,
:atomicrow). “Only hooks re-attached asatomic_after_action/atomic_after_transactionsurvive (update.ex:66,:80-84)”. The re-attachment is atupdate.ex:128-148;:64and:80-83are the dirty-hook disqualifier. - L270 (§3.3).
Ash.DataLayer.data_layer_can?/2is defined atdata_layer.ex:464, not:1538-1546(that range iscan?/2andcan?/3). - Low-precision node citations, L359-364 (§5.1 node table).
call.ex:5-7,eq.ex:15,boolean_expression.ex:7andnot.ex:6point at moduledocs or options, not the struct definitions. The structs and fields named are correct.