Where is the mess?

In the last post, Authorization and Monoids, we looked at predicate monoids—p_any, p_all—as tools for combining checks in predictable ways.

Monoids are:

  • associative: grouping doesn’t affect the result
  • identity element: a neutral element that leaves other elements unchanged
  • closed under the operation: combining any two elements always produces another of the same kind

Predicate monoids also have a bias, which gives us short-circuiting. And they’re commutative, so the order in which we apply them does not matter. We can reorder, extract, or combine predicates and be confident we’re not affecting behavior.

Let’s return to this quote:

It’s possible to encode complicated logic, but it’s also pretty easy to make a mess.

Again: why?

Let’s look at Ash’s auth logic through the lens of predicate monoids.

Bypass

We’ll start with the bypass clauses. These look like a predicate monoid based on logical OR.

So this:

bypass actor_attribute_equals(:role, :owner) do
  authorize_if always()
end

bypass actor_attribute_equals(:role, :admin) do
  authorize_if always()
end

Is logically the same as this (reordered):

bypass actor_attribute_equals(:role, :admin) do
  authorize_if always()
end

bypass actor_attribute_equals(:role, :owner) do
  authorize_if always()
end

The OR monoid has identity false and short-circuits on the first true result.

Also, because this is a monoid, we have an identity case:

# No bypass logic

No bypass clauses evaluate to false (identity of OR).

Within Policy Blocks

Ash uses predicate monoids inside a policy block.

Authorize

The authorize_if clauses combine using logical OR.

So this:

policy action(:update) do
  authorize_if actor_attribute_equals(:role, :admin)
  authorize_if actor_attribute_equals(:role, :editor)
end

Is the same as this:

policy action(:update) do
  authorize_if actor_attribute_equals(:role, :editor)
  authorize_if actor_attribute_equals(:role, :admin)
end

Order doesn’t matter, we still get the same answer.

Ash also supports forbid_if, which follows the same pattern.

Forbid

forbid_if clauses are also combined using the OR monoid.

So this:

policy action(:update) do
  forbid_if exp(published == true)
  forbid_if exp(editing == true)
end

Is the same as this:

policy action(:update) do
  forbid_if exp(editing == true)
  forbid_if exp(published == true)
end

Again: order doesn’t matter, and it’s OR, so we short-circuit at the first true.

Between Policy Blocks

The book states:

Standard policies are AND-ed into the expression, so all need to be authorized for the action to be authorized.

And

With standard policies defined using policy, all applicable policies for an action must apply… It’s not that one policy takes precedence; it’s that both policies apply and have to pass.

But then it warns:

Changing the order of policies within a resource can drastically affect the result.

This is the puzzle. If we cannot change the order then we are no longer in the world of monoid predicate composition. There is something different going on. It turns out that between policy blocks we have a kind of lexicographic ordering.

Let’s say we want to order users by name:

  1. Start with a rule for the last name
  2. If the result is greater or less, STOP and report results
  3. If equal, we continue to the next rule, in this case compare first names.
  4. Continue until we run out of rules, if they all return equal, the result is not orderable

When implemented monoidally, lexicographic ordering is associative and has an identity (:unknown - continue to next). This is how Ash combines policy blocks.

policy action(:update) do
  authorize_if actor_attribute_equals(:role, :admin)
end

policy action(:update) do
  authorize_if actor_attribute_equals(:role, :editor)
end

Here, the first policy will always return the Yes/No decision, so the second will never run. This will only allow admins. If we change the order:

policy action(:update) do
  authorize_if actor_attribute_equals(:role, :editor)
end

policy action(:update) do
  authorize_if actor_attribute_equals(:role, :admin)
end

Now only editors will be allowed to take the action, and it will never check for admins. It’s like switching the order in which you sort users: last name then first name versus first name then last name. Changing the order changes the logic.

Like lexicographic order, if the first policy returns :unknown (like last names being equal), the second policy will run:

policy action(:update) do
  # (This policy returns :unknown)
end

policy action(:update) do
  authorize_if actor_attribute_equals(:role, :editor)
end

And if we reorder:

policy action(:update) do
  authorize_if actor_attribute_equals(:role, :editor)
end

policy action(:update) do
  # (This policy returns :unknown)
end

Because the first policy is NOT :unknown, the second policy is short-circuited.

Back to Bypass

This also means we were wrong about bypass blocks. They do not use OR monoids but instead follow lexicographic ordering. However, they only produce ALLOW or :unknown and never DENY. It’s this limited result that allows them to be freely reordered.

The bypass and policy logic exist in the same composition pipeline, but they effectively operate under different monoidal rules:

  • Between bypass: OR monoid (commutative)
  • Between policies: lexicographic (non-commutative)

When the book suggests “keep all bypass policies at the start”, they are helping us avoid the pitfalls of mixing rules.

Combine authorize and forbid within a Policy

I would expect Ash to combine the result of the authorize_if group with the negation of the forbid_if statements using AND.

policy action(:update) do
  authorize_if actor_attribute_equals(:role, :admin)
  authorize_if actor_attribute_equals(:role, :editor)
  forbid_if exp(published == true)
  forbid_if exp(editing == true)
end

This allows the action if the actor is either an admin OR editor, AND the article is neither published nor editing.

These predicate monoids are associative and commutative so logical order doesn’t matter.

This means even this interleaved version would work the same:

policy action(:update) do
  forbid_if exp(editing == true)
  authorize_if actor_attribute_equals(:role, :editor)
  forbid_if exp(published == true)
  authorize_if actor_attribute_equals(:role, :admin)
end

However, the book states:

If the order of the checks in our policy was reversed as it is here… Then the logic is actually a bit different.”

Wait, what?

This means within the policy, the forbid_if and authorize_if blocks are combined using a lexicographic monoid.

The Mess

For me, the “mess” is the cognitive switching cost between the monoids.

  • “Wait, does order matter here?”
  • “Am I in the OR part or the lexicographic part?”
  • “If I add this check, how does it combine with the others?”

The Trade-Off

Ash prioritizes readable, English-like syntax, making it easier to implement complex logic without having to learn abstract structures. That design choice lowers the barrier to entry and allows developers to express logic in clear, declarative terms.

The tradeoff is that it becomes easy to skip understanding how the logic is actually composed. This can lead to situations where the rules appear straightforward but produce unexpected results.

Resources

Ash Framework

Learn how to use Ash Framework to build robust, maintainable Elixir applications. This book covers the principles and practices of domain-driven design with Ash's declarative approach.

Advanced Functional Programming with Elixir

Dive deeper into functional programming patterns and advanced Elixir techniques. Learn how to build robust, maintainable applications using functional programming principles.