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 after before_transaction, when in code it comes before. Finally, it describes data_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/3 while the changeset is built, then atomic/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 to bulk_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.Validation and 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)

  1. Ash.Changeset.for_create is run by the caller or by Create.changeset/4 (create.ex:272-282). It runs: context, :changeset span, 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’s change/3 may add hooks (changeset.ex:3613-3671). Then global validations: immediate, or turned into before_action hooks when before_action? or delay_global_validations? is set (:4328-4366). Then mark_validated, eager_validate_identities, require_values (:3052-3073). [out]
  2. run/4: reject unsupported atomics (create.ex:19-49); apply multitenancy bypass context (:52-59); open the :action span and [:ash,<domain>,:create] telemetry (:64-88).
  3. 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 as authorize_results hooks (can.ex:1471-1484, :1618-1645), and CannotFilterCreates is raised if there is no transaction or if there are before/around_transaction hooks without allow_post_action_authorization?. Then commit (create.ex:182-186). [out]
  4. 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 :transact is unsupported (:4754-4766).
  5. [T] around_action (first added = outermost) → before_action (in order; halts on invalid; then hydrate_atomic_refs) → body: hydrate_atomic_refs, apply_atomic_constraints, set_action_select, setup_managed_belongs_to_relationships, require_values, then ManualCreate.create/3, or DataLayer.upsert, or DataLayer.create; then manage_relationships (create.ex:343-548).
  6. [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).
  7. Transaction commits (or rolls back on error).
  8. [out] after_transaction (always runs; can rewrite the result), still inside around_transaction (changeset.ex:4989-5014).
  9. [out] Notifications returned by hooks or nested actions are sent, or queued if an outer Ash transaction is open (changeset.ex:4855-4890).
  10. [out] Helpers.load (result loads, reuse_values?), then Helpers.notify for the resource’s own notification (queued if an outer Ash transaction is open), then select, then restrict_field_access (create.ex:565-581).
  11. add_notifications / warn_missed! (create.ex:256-270).

2.2 Update (Ash.update → Ash.Actions.Update.run/4)

  1. Ash.update runs for_update if the changeset is not yet validated (ash.ex:4159-4165). Same build steps as create: every change’s change/3 runs here. [out]
  2. run/4 rejects atomics if {:atomic,:update} is unsupported (update.ex:36-42), then applies multitenancy bypass.
  3. Atomic-upgrade decision, before any span (update.ex:53-155). Each condition gives {:not_atomic, reason}: neither require_atomic? nor atomic_upgrade?; no :expr_error while authorizing; no :update_query; manage_relationships among dirty hooks; caller-added hooks other than after_action/after_transaction; no read action; atomic_upgrade?: false in opts.
    4a. Atomic path. fully_atomic_changeset/4 rebuilds from params and runs every change’s atomic/3 and atomic validations (changeset.ex:813-890). It re-attaches atomic_after_action/atomic_after_transaction and merges caller filters (update.ex:128-179). Then Update.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 returns StaleRecord (update.ex:198-255).
    4b. Not atomic + require_atomic? + data layer capable → MustBeAtomic (update.ex:257-269).
    4c. Classic path. :action span, then do_run: handle_multitenancy (:attribute adds a changeset filter), changeset, authorize (pre_flight?: false; filter checks run a pk+filter SELECT now, outside the transaction; runtime checks become a prepended before_action), add_atomic_validations, commit (update.ex:354-360; can.ex:1591-1596).
  4. commit: managed relationships are registered as a before_action hook (update.ex:463-477). Then with_hooks: around_transaction → before_transaction → [T] around_action → before_action → body (require_values; if changed?, DataLayer.update with changeset.filter; otherwise no write, only an optional re-select under the filter, update.ex:575-668; then manage_relationships) → after_action → authorize_results → commit → after_transaction.
  5. [out] Hook notifications, then Helpers.load, then Helpers.notify, then select, then restrict (update.ex:717-729).

2.3 Destroy (Ash.destroy → Ash.Actions.Destroy.run/4)

  1. soft? true → for_destroy then Ash.Actions.Update.run (whole update lifecycle, including the atomic upgrade) (destroy.ex:19-52).
  2. Hard destroy: no atomic upgrade. :action span, then do_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]
  3. with_hooks: around_transaction → before_transaction → [T] around_action → before_action → body: Helpers.load of the record to be deleted, set tenant, ManualDestroy.destroy/3 or DataLayer.destroy, manage_relationships, then Helpers.notify inside the transaction (queued until the outermost Ash transaction ends) (destroy.ex:216-289) → after_action → authorize_results → commit → after_transaction.
  4. [out] Hook notifications sent; result selected (destroy.ex:313-326).

2.4 Read (Ash.read → Ash.Actions.Read.run/4)

  1. :action span and [:ash,<domain>,:read] telemetry, then around_transaction hooks wrap everything that follows (read.ex:66-120).
  2. do_run: for_read (load opts, :query span, 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).
  3. 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).
  4. 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_transaction hooks (:576-609).
  5. Transaction opens if action.transaction?, or a timeout task is used (:653, :1650-1676).
  6. [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).
  7. [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).
  8. [T?] Notifications sent or stored → after_transaction, which runs inside the transaction when one was opened (:819-823).
  9. [out] Transaction closes; queued notifications flushed (:1716-1720).
  10. [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)

  1. 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]
  2. :action span and [:ash,<domain>,:action] telemetry, then around_transaction (action.ex:53-86).
  3. 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).
  4. [out] Notifications sent, then after_transaction (the reverse of create/update ordering) (action.ex:227-258).
  5. maybe_load on the return type (action.ex:148-177).
    There is no around_action for 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/3 is called inside the function passed to Ash.DataLayer.transaction (action.ex:195-210) and after run_before_transaction_hooks (:183).
  • Create/update/destroy: authorize/2 runs in do_run before commit/3, and therefore before with_hooks, before_transaction and 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_results after after_action, and update/destroy runtime checks run as a prepended before_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

  • @callback declarations: 46, all distinct, with upsert/3 and upsert/4 counted separately.
  • @optional_callbacks: 44.
  • Required: can?/2, resource_to_query/2.
  • @type feature(): 48 alternatives with :transact listed 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 four mix.exs lines.
  • Does not support: update_many.ex:8-31 and lib/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.

  1. 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
  2. Calculations with load were 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
  3. 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
  4. 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)
  5. Query shape: && compiles to ash_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
  6. Query shape: a cartesian product from parent() refs combined with or. 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 a first aggregate used in a sort generated “a rather inefficient and slow query” (LATERAL + array_agg). https://github.com/ash-project/ash_postgres/issues/215
  7. N+1: manage_relationship works 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
  8. 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
  9. 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), where get_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
  10. 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 at changeset.ex:5132, :5222 and :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_callbacks is at change.ex:460 (lines 460-465), not 446-451.
  • :transact appears at data_layer.ex:89 and :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_query at :579 before before_transaction at :609; the transaction when transaction? is set (:1650-1676); after_transaction inside the transaction.
  • Update: rebuild via fully_atomic_changeset; routing through Update.Bulk.run with [: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 before after_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:

  1. 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 when action.transaction? == false (can.ex:1392-1408). The check then runs immediately at pre-flight via run_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. Only before_transaction/around_transaction hooks 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.
  2. Row 1 cites the wrong code. can.ex:1591-1645 is the filter and create-stash code. Strict checks run in run_check/4, via Ash.Authorizer.strict_check (can.ex:1027-1300, call at :1083).
  3. 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.
  4. §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.
  5. 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/4 has no :strategy option. Its option schema (ash/lib/ash.ex:673-741) contains no strategy key. It batches inputs and calls Ash.DataLayer.bulk_create when :bulk_create is supported (ash/lib/ash/actions/create/bulk.ex:186). Changesets with around_transaction/around_action hooks are routed one by one through the single-record path. Every other changeset gets before_transaction per changeset, then batched before_action, data-layer bulk_create, after_action and after_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_many description 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_strategy for 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, :atomic for 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 match data_layer.ex:379-421; required resource_to_query/2 at :180 and can?/2 at :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_callbacks except Ash.Policy.FilterCheck (L642). It still lists init/1, requires_original_data?/2, prefer_expanded_description?/0, eager_evaluate?/0 as optional callbacks. FilterCheck declares only filter/3 and the optional reject/3; the rest are Ash.Policy.Check callbacks with defaults from use. 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-115 lists before-action hooks before authorization; the code does the reverse (read.ex:579 vs :781).
  • actions.md:635 says global preparations, validations and changes are outside the lifecycle; changeset.ex:3616-3621 and :4328-4344 run 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 only update.ex and its test. It corresponds to the merge at update.ex:166-179. Add one fact: the issue says caller filters set before for_update still 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:

  1. §7.1 L546 still says “distinct cannot 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.
  2. §7.2 L550 still calls read.ex:4056-4090 a dedicated lateral-join aggregate path that dispatches on lateral_join_source. Those lines are run_count_query/2, the count query for paginated relationship loads (the run_aggregate_query_with_lateral_join call is at read.ex:4079). Relationship aggregates are planned by AshSql.Aggregate (see R2.10c). The revision log says this was corrected; it was not.
  3. L706 cites ash_functions.ex:4 for the version constant, and L315 cites ash_functions.ex:5 for the error prefix. The correct lines are :6 (@latest_version 7) and :7 (@error_prefix).
  4. L212 cites changeset.ex:875-877 for apply_atomic_constraints. Those lines are its call site inside fully_atomic_changeset; the definition is at changeset.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-1645 cited for strict checks; can.ex:1392-1420 cited for “raises if the action will not transact”; ash.ex:574-578 with update-actions.md cited as covering bulk_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_constraints and tenant.
  • opts[:atomics] arrives from update.ex:120-124 as the original changeset’s atomic_changes merged with its attribute_changes. So attribute values that change/3 set during for_update are carried into the atomic changeset as literal atomics; only hooks, filters and other side effects of change/3 are 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]) → set changed? → atomic_defaults (static and lazy update_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_changes runs, in order: action changes and validations as declared, then global changes, then global validations (unless skip_global_validations?), each via run_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, but could_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 in opts[:meta][:order] order. It matches inputs to existing related records and applies on_lookup/on_no_match/on_match/on_missing, with one nested Ash.create/Ash.update/Ash.destroy/Ash.read_one per 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 when opts[:bulk?] == true, on_no_match is {: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 in data_facts for that clause (:2088-2110).
  • Otherwise check_facts_until_known (:2136-2157) picks the next unknown fact, runs check_fact, and loops until some scenario applies or none remain.
  • check_fact (:2159+) calls Ash.Policy.Check.check/4 once over all records, a batch call, and stores the authorized primary keys in data_facts. It raises for a non-read action unless the data layer supports :transact and the resource is currently in a transaction.
  • The result is :authorized if no record was forbidden, otherwise {:data, allowed_records}. Reads use {:data, …} to filter; for writes, Ash.Can treats 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)

  1. §7.1 L546: delete the “distinct cannot be combined with lateral joins” sentence.
  2. §7.2 L550: re-describe read.ex:4056-4090 as run_count_query/2, and add the lateral/grouped answer from R2.10c.
  3. §6.6 row 3: give the three real outcomes of defer_changeset_authorization? (R2.3 item 1). Fix the row 1 citation to can.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.
  4. §2.3: remove bulk_create from the strategy scope and describe its batching and hooks (R2.4). Fix the update_many description. Name :update_query + :expr_error for destroy.
  5. §11 FilterCheck row: optional callbacks are reject/3 only.
  6. §12.B: label the #1770 and #2654 strings as titles. Add that #2969 affected only filters added by the action’s own changes.
  7. Line numbers listed in R2.2 and in R2.8 items 3 and 4.
  8. 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-3958 plus warn_if_before_action_load_changed: the loads are collected on the query but ignored, with a warning.
  9. Revision log: correct the word count, and remove the claims that §7.1’s distinct sentence and §7.2’s read.ex:4056-4090 description 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 with pipeline is at changeset.ex:857-879, so the cited :860-880 is acceptable. Also verified: %OriginalDataNotAvailable{} at :831, atomic_defaults at :895, and the update.ex:120-124 merge of atomic_changes with attribute_changes.
  • §1.5 managed relationships. managed_relationships.ex:20-35, :28-29, :576-600, :879-890 and the nested-action sites are correct.
  • §7.2 aggregate strategy. implementation.ex:75-87, :110, aggregate.ex:146-150 and ash_sqlite/lib/sql_implementation.ex:13 are 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?/2 is at :464); call.ex:5-7, eq.ex:15, boolean_expression.ex:7 and not.ex:6 (these point at moduledocs or options, not the struct definitions); calculations.ex:531-534 (run_calculate starts at :534).
  • Does not support: policies.md:834-836 (cited for alter_filter nil-rewriting; those lines are about can_see_fields?); changeset.ex:63-90 (cited for error aggregation; it is the defstruct).

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.

  1. 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 :transact or action.transaction? == false. When the resource is already in a transaction opened by the caller, the immediate run_changeset_query SELECT 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.
  2. L575 (§7.2). The document says the lateral variant run_aggregate_query_with_lateral_join/5 is “called from the read path at read.ex:4079” and also that read.ex:4056-4090 “is not that path”. These contradict each other. The fact: the only core call of Ash.DataLayer.run_aggregate_query_with_lateral_join is at read.ex:4079, inside run_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.
  3. L517 (§6.7). The quoted raise text is not verbatim. The source message is: “Attempted to use a check/4 function 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).
  4. L246 (§2.3, bulk_create row). “before_action/after_action/after_transaction per batch” is wrong. These are each changeset’s own hooks, run per changeset while the batch is iterated (create/bulk.ex:1183-1187 maps over the batch and calls run_before_actions(changeset); :1589 run_after_actions(result, changeset, []); :1647 run_after_transactions). The data-layer bulk_create call itself is per batch.
  5. L168 (§1.5). The cited create.ex:340-353, :390-399, :508-517 are off. Use create.ex:350-355 (setup_managed_belongs_to_relationships) and :405-409, :449-453, :497-501, :513-517 (manage_relationships calls after manual, result, upsert and create respectively).
  6. 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:29 is the invalid-changeset error path);
    • changeset.ex:63-90 is the changeset defstruct and does not show aggregation; aggregation is create.ex:205-215 plus splode/lib/splode.ex:460-482.
  7. L481 (§6.5). policies.md:834-836 does not support “alter_filter/3 rewrites refs to forbidden fields into nil”. Rely on authorizer.ex:796-808; the guide’s statement about filters is at policies.md:838.
  8. L234 (§2.3, :atomic row). “Only hooks re-attached as atomic_after_action/atomic_after_transaction survive (update.ex:66, :80-84)”. The re-attachment is at update.ex:128-148; :64 and :80-83 are the dirty-hook disqualifier.
  9. L270 (§3.3). Ash.DataLayer.data_layer_can?/2 is defined at data_layer.ex:464, not :1538-1546 (that range is can?/2 and can?/3).
  10. Low-precision node citations, L359-364 (§5.1 node table). call.ex:5-7, eq.ex:15, boolean_expression.ex:7 and not.ex:6 point at moduledocs or options, not the struct definitions. The structs and fields named are correct.