Skip to content

fix: honour before_action?: true on validations of event-tracked actions - #97

Merged
Torkan merged 1 commit into
ash-project:mainfrom
Fasp96:fix/replay-validation-wrapper-before-action
Sep 12, 2026
Merged

Torkan merged 1 commit into
ash-project:mainfrom
Fasp96:fix/replay-validation-wrapper-before-action

Conversation

@Fasp96

@Fasp96 Fasp96 commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Problem

On a resource with the AshEvents.Events extension, validate SomeValidation, before_action?: true inside a tracked action is silently ignored — the validation still runs at for_action time.

WrapActions rewrites every action-level %Ash.Resource.Validation{} into a %Ash.Resource.Change{} wrapping ReplayValidationWrapper:

https://github.com/ash-project/ash_events/blob/main/lib/events/transformers/wrap_actions.ex#L116-L135

It carries on, only_when_valid?, description, where and message across, but %Ash.Resource.Change{} has no before_action? field, so the flag cannot survive the rewrite. The wrapper then calls validate/3 inline from its change/3. Ash.Changeset.validate/5 is the only code that honours the flag, and it only ever sees a change — so the flag is dropped without a warning.

before_action? currently appears nowhere in the codebase.

Why it matters

Two things break:

  1. Anything the validation needs from an earlier before_action hook is still unset, so it silently validates against the wrong data. We hit this on a resource where a Prepare* change derives campaign_id in a hook: the validation read nil and resolved a global default limit instead of the record's, letting bad input through to fail later in a background job.
  2. before_action?: true is the documented way to run a validation after authorization. A validation that reads data with authorize?: false and then runs before authorization answers for any actor who can merely attempt the action, and turns a Forbidden into an Invalid.

Because only tracked actions are rewritten, whether the flag works depends on whether the action is in the resource's only_actions/ignore_actions — which makes it very easy to miss. Two otherwise identical actions behave differently based only on event tracking.

Fix

The wrapper already receives the whole %Ash.Resource.Validation{} in opts[:validation], so it can register a before_action hook when the struct asks for one.

  • only_when_valid? is re-checked inside the hook, mirroring Ash.Changeset.validate/5. Relying on the generated change's field alone would evaluate it before the hook runs.
  • Replay keeps running the validation inline. A hook registered during replay by a validation outside allowed_change_modules is discarded by the existing hook-restoring branch, which would make the validation silently never run — so replay semantics are unchanged.

Tests

Two identical create actions on Accounts.Org — one tracked, one in ignore_actions — both delay a validation that reads an attribute a before_action hook sets. They must behave identically.

Before this change the tracked action failed and the ignored one passed; after, both pass.

mix test is otherwise unchanged: the one pre-existing failure (replay events on event log missing clear function throws RuntimeError) fails on main too, unrelated to this change.

Notes / out of scope

  • where is still evaluated when the change runs rather than inside the hook, because the transformer puts it on the generated %Ash.Resource.Change{}. Ash.Changeset evaluates a validation's where inside the hook. Left alone to keep this diff to the ordering bug.
  • The transformer also hard-codes always_atomic?: false on the generated change, dropping the validation's own value. Also left alone.

`WrapActions` rewrites every action-level `%Ash.Resource.Validation{}` into a
`%Ash.Resource.Change{}` wrapping `ReplayValidationWrapper`, which calls
`validate/3` inline from its `change/3`. `%Ash.Resource.Change{}` has no
`before_action?` field, and `Ash.Changeset.validate/5` — the only code that
honours the flag — never sees a validation, so `before_action?: true` was
silently dropped and the validation kept running at `for_action` time.

Two things break as a result: anything the validation needs from an earlier
`before_action` hook is still unset, so it silently validates against the wrong
data; and the validation runs before authorization, so a read done with
`authorize?: false` answers for any actor who can merely attempt the action, and
a `Forbidden` becomes an `Invalid`. Whether the flag works at all depends on
whether the action is in the resource's `only_actions`/`ignore_actions`, which
makes it easy to miss.

The wrapper already receives the whole validation struct in `opts[:validation]`,
so it can register a `before_action` hook when the struct asks for one.
`only_when_valid?` is re-checked inside the hook, mirroring
`Ash.Changeset.validate/5`; relying on the generated change's field alone would
evaluate it before the hook runs.

Replay keeps running the validation inline. A hook registered there by a
validation outside `allowed_change_modules` is discarded by the existing
hook-restoring branch, which would make the validation silently never run.

Tested with two identical create actions on `Accounts.Org` — one tracked, one in
`ignore_actions` — that both delay a validation reading an attribute a
`before_action` hook sets. Before this change the tracked one failed and the
ignored one passed.
@Torkan
Torkan merged commit 70fb2d6 into ash-project:main Sep 12, 2026
23 of 26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants